From 34251ba046174f748c793e4736677e80edffeddf Mon Sep 17 00:00:00 2001 From: Ray Date: Mon, 27 Jul 2026 15:44:23 +0800 Subject: [PATCH 01/23] Add PageIndex Flash for ultra fast LLM-free tree extraction --- pageindex/flash/README.md | 44 ++ pageindex/flash/__init__.py | 5 + pageindex/flash/api.py | 76 +++ pageindex/flash/blocks/__init__.py | 56 ++ pageindex/flash/blocks/build.py | 173 ++++++ pageindex/flash/blocks/join_rules.py | 326 +++++++++++ pageindex/flash/classification/__init__.py | 131 +++++ pageindex/flash/classification/body_text.py | 209 +++++++ .../flash/classification/header_footer.py | 481 +++++++++++++++++ .../flash/classification/keyword_tables.py | 113 ++++ .../flash/classification/toc_boilerplate.py | 375 +++++++++++++ pageindex/flash/clustering/__init__.py | 68 +++ pageindex/flash/clustering/build.py | 211 ++++++++ pageindex/flash/clustering/merge_rules.py | 190 +++++++ pageindex/flash/columns/__init__.py | 47 ++ pageindex/flash/columns/gutters.py | 343 ++++++++++++ pageindex/flash/columns/splitting.py | 221 ++++++++ pageindex/flash/data/boilerplate_phrases.json | 1 + pageindex/flash/data/dictionaries.json | 1 + pageindex/flash/data/glyph_name_table.json | 1 + pageindex/flash/data/normalized_unicodes.json | 1 + pageindex/flash/data/script_bucket_table.json | 1 + pageindex/flash/heading_detection/__init__.py | 120 ++++ .../flash/heading_detection/candidates.py | 214 ++++++++ .../flash/heading_detection/detectors.py | 492 +++++++++++++++++ .../flash/heading_detection/keyword_tables.py | 96 ++++ .../flash/heading_detection/neighbors.py | 151 ++++++ .../flash/heading_detection/page_scan.py | 474 ++++++++++++++++ .../heading_detection/style_detectors.py | 383 +++++++++++++ .../flash/heading_detection/text_checks.py | 214 ++++++++ pageindex/flash/labels/__init__.py | 48 ++ pageindex/flash/labels/caption_regions.py | 366 +++++++++++++ pageindex/flash/labels/caption_text.py | 171 ++++++ pageindex/flash/main.py | 294 ++++++++++ pageindex/flash/model/__init__.py | 121 +++++ pageindex/flash/model/block.py | 288 ++++++++++ pageindex/flash/model/char_stats.py | 184 +++++++ pageindex/flash/model/numbering.py | 131 +++++ pageindex/flash/model/rects.py | 229 ++++++++ pageindex/flash/model/span_line.py | 224 ++++++++ pageindex/flash/outline/__init__.py | 54 ++ pageindex/flash/outline/filtering.py | 241 +++++++++ pageindex/flash/outline/tree.py | 132 +++++ pageindex/flash/outline_assembly/__init__.py | 110 ++++ pageindex/flash/outline_assembly/assembly.py | 341 ++++++++++++ .../flash/outline_assembly/candidates.py | 255 +++++++++ pageindex/flash/outline_assembly/cliques.py | 403 ++++++++++++++ pageindex/flash/outline_assembly/selection.py | 385 +++++++++++++ .../flash/outline_assembly/style_context.py | 365 +++++++++++++ .../flash/parser_pdfium_charlevel/__init__.py | 168 ++++++ .../parser_pdfium_charlevel/char_extract.py | 363 +++++++++++++ .../parser_pdfium_charlevel/cmap_parse.py | 316 +++++++++++ .../parser_pdfium_charlevel/code_walk.py | 249 +++++++++ .../parser_pdfium_charlevel/content_stream.py | 424 +++++++++++++++ .../parser_pdfium_charlevel/font_unicode.py | 388 +++++++++++++ .../flash/parser_pdfium_charlevel/geometry.py | 222 ++++++++ .../parser_pdfium_charlevel/glyph_tables.py | 82 +++ .../flash/parser_pdfium_charlevel/merge.py | 511 ++++++++++++++++++ .../parser_pdfium_charlevel/pdf_objects.py | 191 +++++++ .../flash/parser_pdfium_charlevel/pipeline.py | 268 +++++++++ .../flash/parser_pdfium_charlevel/remerge.py | 325 +++++++++++ .../parser_pdfium_charlevel/text_normalize.py | 288 ++++++++++ .../parser_pdfium_charlevel/unicode_apply.py | 375 +++++++++++++ pageindex/flash/phases/__init__.py | 50 ++ pageindex/flash/phases/line_numbers.py | 162 ++++++ pageindex/flash/phases/page_view.py | 123 +++++ pageindex/flash/stats/__init__.py | 48 ++ pageindex/flash/stats/aggregates.py | 313 +++++++++++ pageindex/flash/stats/scripts.py | 85 +++ pageindex/flash/title/__init__.py | 54 ++ pageindex/flash/title/detect.py | 140 +++++ pageindex/flash/title/dicts.py | 35 ++ pageindex/flash/title/scoring.py | 264 +++++++++ pageindex/flash/tokens/__init__.py | 105 ++++ pageindex/flash/tokens/hashing.py | 139 +++++ pageindex/flash/tokens/token_types.py | 243 +++++++++ pageindex/flash/tokens/tokenizer.py | 271 ++++++++++ pageindex/flash/tokens/tries.py | 336 ++++++++++++ requirements.txt | 3 + run_pageindex.py | 41 +- 80 files changed, 16126 insertions(+), 16 deletions(-) create mode 100644 pageindex/flash/README.md create mode 100644 pageindex/flash/__init__.py create mode 100644 pageindex/flash/api.py create mode 100644 pageindex/flash/blocks/__init__.py create mode 100644 pageindex/flash/blocks/build.py create mode 100644 pageindex/flash/blocks/join_rules.py create mode 100644 pageindex/flash/classification/__init__.py create mode 100644 pageindex/flash/classification/body_text.py create mode 100644 pageindex/flash/classification/header_footer.py create mode 100644 pageindex/flash/classification/keyword_tables.py create mode 100644 pageindex/flash/classification/toc_boilerplate.py create mode 100644 pageindex/flash/clustering/__init__.py create mode 100644 pageindex/flash/clustering/build.py create mode 100644 pageindex/flash/clustering/merge_rules.py create mode 100644 pageindex/flash/columns/__init__.py create mode 100644 pageindex/flash/columns/gutters.py create mode 100644 pageindex/flash/columns/splitting.py create mode 100644 pageindex/flash/data/boilerplate_phrases.json create mode 100644 pageindex/flash/data/dictionaries.json create mode 100644 pageindex/flash/data/glyph_name_table.json create mode 100644 pageindex/flash/data/normalized_unicodes.json create mode 100644 pageindex/flash/data/script_bucket_table.json create mode 100644 pageindex/flash/heading_detection/__init__.py create mode 100644 pageindex/flash/heading_detection/candidates.py create mode 100644 pageindex/flash/heading_detection/detectors.py create mode 100644 pageindex/flash/heading_detection/keyword_tables.py create mode 100644 pageindex/flash/heading_detection/neighbors.py create mode 100644 pageindex/flash/heading_detection/page_scan.py create mode 100644 pageindex/flash/heading_detection/style_detectors.py create mode 100644 pageindex/flash/heading_detection/text_checks.py create mode 100644 pageindex/flash/labels/__init__.py create mode 100644 pageindex/flash/labels/caption_regions.py create mode 100644 pageindex/flash/labels/caption_text.py create mode 100644 pageindex/flash/main.py create mode 100644 pageindex/flash/model/__init__.py create mode 100644 pageindex/flash/model/block.py create mode 100644 pageindex/flash/model/char_stats.py create mode 100644 pageindex/flash/model/numbering.py create mode 100644 pageindex/flash/model/rects.py create mode 100644 pageindex/flash/model/span_line.py create mode 100644 pageindex/flash/outline/__init__.py create mode 100644 pageindex/flash/outline/filtering.py create mode 100644 pageindex/flash/outline/tree.py create mode 100644 pageindex/flash/outline_assembly/__init__.py create mode 100644 pageindex/flash/outline_assembly/assembly.py create mode 100644 pageindex/flash/outline_assembly/candidates.py create mode 100644 pageindex/flash/outline_assembly/cliques.py create mode 100644 pageindex/flash/outline_assembly/selection.py create mode 100644 pageindex/flash/outline_assembly/style_context.py create mode 100644 pageindex/flash/parser_pdfium_charlevel/__init__.py create mode 100644 pageindex/flash/parser_pdfium_charlevel/char_extract.py create mode 100644 pageindex/flash/parser_pdfium_charlevel/cmap_parse.py create mode 100644 pageindex/flash/parser_pdfium_charlevel/code_walk.py create mode 100644 pageindex/flash/parser_pdfium_charlevel/content_stream.py create mode 100644 pageindex/flash/parser_pdfium_charlevel/font_unicode.py create mode 100644 pageindex/flash/parser_pdfium_charlevel/geometry.py create mode 100644 pageindex/flash/parser_pdfium_charlevel/glyph_tables.py create mode 100644 pageindex/flash/parser_pdfium_charlevel/merge.py create mode 100644 pageindex/flash/parser_pdfium_charlevel/pdf_objects.py create mode 100644 pageindex/flash/parser_pdfium_charlevel/pipeline.py create mode 100644 pageindex/flash/parser_pdfium_charlevel/remerge.py create mode 100644 pageindex/flash/parser_pdfium_charlevel/text_normalize.py create mode 100644 pageindex/flash/parser_pdfium_charlevel/unicode_apply.py create mode 100644 pageindex/flash/phases/__init__.py create mode 100644 pageindex/flash/phases/line_numbers.py create mode 100644 pageindex/flash/phases/page_view.py create mode 100644 pageindex/flash/stats/__init__.py create mode 100644 pageindex/flash/stats/aggregates.py create mode 100644 pageindex/flash/stats/scripts.py create mode 100644 pageindex/flash/title/__init__.py create mode 100644 pageindex/flash/title/detect.py create mode 100644 pageindex/flash/title/dicts.py create mode 100644 pageindex/flash/title/scoring.py create mode 100644 pageindex/flash/tokens/__init__.py create mode 100644 pageindex/flash/tokens/hashing.py create mode 100644 pageindex/flash/tokens/token_types.py create mode 100644 pageindex/flash/tokens/tokenizer.py create mode 100644 pageindex/flash/tokens/tries.py diff --git a/pageindex/flash/README.md b/pageindex/flash/README.md new file mode 100644 index 000000000..fa313df4e --- /dev/null +++ b/pageindex/flash/README.md @@ -0,0 +1,44 @@ +# PageIndex Flash + +Builds a PageIndex tree structure from a PDF using layout statistics alone. +No LLM, no API key, no OCR, no network. Runs in seconds, fully offline. + +## Usage + +```python +from pageindex.flash import page_index_flash + +tree = page_index_flash("paper.pdf") +``` + +```bash +python3 run_pageindex.py --pdf_path document.pdf --flash +``` + +Accepts a path (`str` or `pathlib.Path`) or an `io.BytesIO` stream. Raises on a +missing, non-PDF, encrypted, empty, or unreadable file. + +## Output + +```python +{ + "doc_name": str, + "doc_title": str, + "structure": [ + {"title": str, "start_index": int, "end_index": int, "nodes": [...]} + ], +} +``` + +Page indexes are 1-based. `nodes` nests recursively. + +## Limits + +- Scanned PDFs without embedded text are not supported. +- Encrypted PDFs need preprocessing first. +- Headings drawn as vector paths, or very decorative layouts, can be missed. +- Titles are taken from the document text as-is. + +## Dependencies + +`pypdfium2`, `PyPDF2`, `regex`, `sortedcontainers`. diff --git a/pageindex/flash/__init__.py b/pageindex/flash/__init__.py new file mode 100644 index 000000000..43268ed22 --- /dev/null +++ b/pageindex/flash/__init__.py @@ -0,0 +1,5 @@ +"""PageIndex Flash: LLM-free tree structure extraction from PDF layout statistics.""" + +from .api import page_index_flash + +__all__ = ["page_index_flash"] diff --git a/pageindex/flash/api.py b/pageindex/flash/api.py new file mode 100644 index 000000000..5081c27f0 --- /dev/null +++ b/pageindex/flash/api.py @@ -0,0 +1,76 @@ +"""Public API for PageIndex Flash. The only supported entry point is :func:`page_index_flash`. Everything else in this package is internal pipeline machinery.""" + +from __future__ import annotations + +from io import BytesIO +from pathlib import Path +from typing import BinaryIO + +import pypdfium2 as pdfium + +from .main import extract_toc + + +def _is_pdfium_password_error(exc: Exception) -> bool: + msg = str(exc).lower() + return "password" in msg or "security" in msg or "encrypted" in msg + + +def _validate_path(path: Path) -> str: + if not path.exists(): + raise FileNotFoundError(f"PDF file not found: {path}") + if not path.is_file(): + raise ValueError(f"PDF path is not a file: {path}") + if path.suffix.lower() != ".pdf": + raise ValueError(f"PDF file must have a .pdf extension: {path}") + with path.open("rb") as score_value: + if score_value.read(5) != b"%PDF-": + raise ValueError(f"File does not look like a PDF: {path}") + return str(path) + + +def _validate_stream(stream: BinaryIO) -> BinaryIO: + try: + pos = stream.tell() + head = stream.read(5) + stream.seek(pos) + except Exception as exc: # noqa: BLE001 - normalize stream capability errors + raise TypeError("PDF stream must be seekable and readable") from exc + if head != b"%PDF-": + raise ValueError("Input stream does not look like a PDF") + return stream + + +def _validate_pdf(pdf): + if isinstance(pdf, (str, Path)): + handle = _validate_path(Path(pdf)) + restore = None + elif isinstance(pdf, BytesIO): + handle = _validate_stream(pdf) + restore = pdf.tell() + else: + raise TypeError("page_index_flash(pdf) expects a PDF path or io.BytesIO stream") + + doc = None + try: + doc = pdfium.PdfDocument(handle) + if len(doc) == 0: + raise ValueError("PDF contains no pages") + except pdfium.PdfiumError as exc: + if _is_pdfium_password_error(exc): + raise ValueError("PDF is encrypted or password-protected") from exc + raise ValueError(f"Could not open PDF: {exc}") from exc + finally: + if doc is not None: + doc.close() + if restore is not None: + pdf.seek(restore) + return pdf + + +def page_index_flash(pdf) -> dict: + """Build a PageIndex tree structure from a PDF using layout statistics, without an LLM. Args: pdf: path to a PDF file (``str`` or ``pathlib.Path``) or an in-memory binary stream (``io.BytesIO``). Returns: dict with keys ``doc_name``, ``doc_title`` and ``structure`` (a list of nested ``{"title", "start_index", "end_index", "nodes"}`` dicts; page indexes are 1-based). """ + return extract_toc(_validate_pdf(pdf)) + + +__all__ = ["page_index_flash"] diff --git a/pageindex/flash/blocks/__init__.py b/pageindex/flash/blocks/__init__.py new file mode 100644 index 000000000..5da9d4d2f --- /dev/null +++ b/pageindex/flash/blocks/__init__.py @@ -0,0 +1,56 @@ +"""Block clustering. This module walks page lines in reading order, extends nearby compatible +blocks, starts a new block when no neighbor fits, and then splits simple +"heading + body" two-line blocks where the first line is a standalone section +heading. The clustering pass must return blocks, not raw lines. Reading-order assignment +then uses each block's first line to find the column index; doing that on raw +lines would read an unrelated first-span flag. +""" + +from typing import Optional + +from sortedcontainers import SortedKeyList + +import json +from pathlib import Path + +from ..model import ( + style_key, + magnitude_ratio, + left_aligned, + right_aligned, + center_aligned, + x_centers_close, + Rect, + last_span, + avg_char_width, + EMPTY_RECT, + left_edge_key, + reading_order_key, + numbering_kind, + Line, + case_signal, + last_line_of, + first_span_of, + letter_count, + dominant_style_of, + is_upper_dominant, + Block, + _max_nan_propagating, +) +from ..stats import DocStats, PageStats +from ..tokens import set_case_fold, TrieConfig, build_trie, tokenize_block + +from .join_rules import ( + _DICT_PATH, + _DICTS, + SECTION_HEADING_TRIE, + BlockClusterContext, + should_join_line_to_block, +) +from .build import ( + split_heading_body_blocks, + _set_add, + cluster_lines_into_blocks, +) + +__all__ = ["BlockClusterContext", "should_join_line_to_block", "cluster_lines_into_blocks", "split_heading_body_blocks", "SECTION_HEADING_TRIE"] diff --git a/pageindex/flash/blocks/build.py b/pageindex/flash/blocks/build.py new file mode 100644 index 000000000..d12245c96 --- /dev/null +++ b/pageindex/flash/blocks/build.py @@ -0,0 +1,173 @@ +"""Clusters lines into blocks and splits heading-body blocks.""" + +from __future__ import annotations + +from sortedcontainers import SortedKeyList + +from ..model import ( + style_key, + magnitude_ratio, + left_aligned, + right_aligned, + center_aligned, + x_centers_close, + Rect, + last_span, + avg_char_width, + EMPTY_RECT, + left_edge_key, + reading_order_key, + numbering_kind, + Line, + case_signal, + last_line_of, + first_span_of, + letter_count, + dominant_style_of, + is_upper_dominant, + Block, + _max_nan_propagating, +) +from ..tokens import set_case_fold, TrieConfig, build_trie, tokenize_block + +from .join_rules import ( + SECTION_HEADING_TRIE, + BlockClusterContext, + should_join_line_to_block, +) + + +# --------------------------------------------------------------------------- # +# Two-line block split post-process # +# --------------------------------------------------------------------------- # + + +def split_heading_body_blocks(input_blocks: list[Block]) -> list[Block]: + """Split blocks whose first line is a section heading followed by body text.""" + from ..labels import trie_matches_all, advance_past_line + split_output_blocks: list[Block] = [] + for input_block in input_blocks: + first_line = input_block.line() # first line + # Skip blocks that obviously aren't "heading + body": + # - 1-line blocks + # - small/short blocks + # - first-span style == last-span style AND wide first line + if ( + input_block.line_count() <= 1 + or (input_block.bbox_height() >= 0.6 * input_block.bbox_width() and input_block.char_count() < 20 * input_block.line_count()) + or (style_key(first_span_of(input_block)) == style_key(last_span(last_line_of(input_block))) and first_line.bbox_width() > 0.5 * input_block.bbox_width()) + ): + split_output_blocks.append(input_block) + continue + block_tokens = tokenize_block(input_block) + first_line_tokens = block_tokens.slice(0, advance_past_line(block_tokens, first_line, 0)) + split_token = block_tokens.token_at(first_line_tokens.length) + if split_token is None or split_token.primary_slot == 3: + split_output_blocks.append(input_block) + continue + if not trie_matches_all(SECTION_HEADING_TRIE, first_line_tokens): + split_output_blocks.append(input_block) + continue + # Split: first block holds the heading line; second holds the rest. + split_heading_block = Block() + split_heading_block.add_line(first_line) + split_body_block = Block() + for line_idx in range(1, input_block.line_count()): + split_body_block.add_line(input_block.primary_slot[line_idx]) + split_output_blocks.append(split_heading_block) + split_output_blocks.append(split_body_block) + return split_output_blocks + + +# --------------------------------------------------------------------------- # +# Block-clustering driver # +# --------------------------------------------------------------------------- # + + +def _set_add(tree: SortedKeyList, block: Block) -> None: + """Sorted-set insertion semantics: when another block has the same left-edge ordering key, the new block is ignored instead of kept as a multiset duplicate.""" + idx = tree.bisect_left(block) + if idx < len(tree) and left_edge_key(tree[idx]) == left_edge_key(block): # type: ignore[arg-type] + return # key collision -> sorted set.add drops the element + tree.add(block) + + +def cluster_lines_into_blocks(ctx: BlockClusterContext) -> list[Block]: + """Walk lines, extend existing blocks when compatible, otherwise open a block. Returns blocks sorted bottom, then top, then left, then right before reading-order assignment.""" + # Tree of *blocks* sorted by (left, right, top desc, bottom desc) + tree: SortedKeyList = SortedKeyList(key=left_edge_key) + clustered_blocks: list[Block] = [] + + lines = ctx.secondary_slot + line_count = len(lines) + for line_index in range(line_count): + candidate_line = lines[line_index] + next_line = lines[line_index + 1] if line_index + 1 < line_count else None + + # The new line wrapped as a block (used as the tree key for lookups). + seed_block = Block().add_line(candidate_line) + + # Collect candidate blocks whose horizontal interval overlaps e_line. + # * predecessors: walk backwards from g_seed_block's left, gather + # blocks whose right edge >= e_line.left. + # * successors: walk forwards, gather blocks whose left edge <= e_line.right. + candidate_blocks: list[Block] = [] + # Predecessors by decreasing block-order key. + # Predecessor walk starts at the largest key <= the seed key. + idx_pred = tree.bisect_right(seed_block) + block = idx_pred - 1 + while block >= 0: + existing_block: Block = tree[block] # type: ignore[assignment] + if existing_block.right_edge() < candidate_line.left_edge(): + break + candidate_blocks.append(existing_block) + block -= 1 + # Successors by increasing block-order key. + # Successor walk starts at the smallest key >= the seed key. An exact + # key-equal node is intentionally visited by both walks. + idx_succ = tree.bisect_left(seed_block) + block = idx_succ + while block < len(tree): + existing_block = tree[block] # type: ignore[assignment] + if existing_block.left_edge() > candidate_line.right_edge(): + break + candidate_blocks.append(existing_block) + block += 1 + + # Sort candidates by bottom, then top, left, and right. + candidate_blocks.sort(key=lambda block: (block.bottom_edge(), block.top_edge(), block.left_edge(), block.right_edge())) + + did_join = False + # Capture the first candidate (closest) before mutating the list + first_candidate = candidate_blocks[0] if candidate_blocks else None + for existing_block in candidate_blocks: + if not did_join and first_candidate is not None and should_join_line_to_block( + ctx, existing_block, candidate_line, next_line, first_candidate + ): + # Join: remove m from tree, extend with e_line, re-add. + try: + tree.remove(existing_block) + except ValueError: + pass + existing_block.add_line(candidate_line) + _set_add(tree, existing_block) + did_join = True + else: + # Doesn't take this line -- block is "closed", emit it. + clustered_blocks.append(existing_block) + try: + tree.remove(existing_block) + except ValueError: + pass + if not did_join: + _set_add(tree, seed_block) + + # Drain remaining open blocks + for block in tree: + clustered_blocks.append(block) + + # Post-process to split 2-line "heading+body" blocks when the first line + # matches section, abstract, or references keywords. + clustered_blocks = split_heading_body_blocks(clustered_blocks) + clustered_blocks.sort(key=reading_order_key) + return clustered_blocks diff --git a/pageindex/flash/blocks/join_rules.py b/pageindex/flash/blocks/join_rules.py new file mode 100644 index 000000000..9f96e6da0 --- /dev/null +++ b/pageindex/flash/blocks/join_rules.py @@ -0,0 +1,326 @@ +"""Line-to-block joining rules and the section-heading trie.""" + +from __future__ import annotations + +from typing import Optional + +import json +from pathlib import Path + +from ..model import ( + style_key, + magnitude_ratio, + left_aligned, + right_aligned, + center_aligned, + x_centers_close, + Rect, + last_span, + avg_char_width, + EMPTY_RECT, + left_edge_key, + reading_order_key, + numbering_kind, + Line, + case_signal, + last_line_of, + first_span_of, + letter_count, + dominant_style_of, + is_upper_dominant, + Block, + _max_nan_propagating, +) +from ..stats import DocStats, PageStats +from ..tokens import set_case_fold, TrieConfig, build_trie, tokenize_block + + +# Combined heading trie used to detect "first line is a section header" patterns +# when splitting two-line blocks. +_DICT_PATH = Path(__file__).parent.parent / "data" / "dictionaries.json" +_DICTS = json.loads(_DICT_PATH.read_text(encoding="utf-8")) +SECTION_HEADING_TRIE = build_trie( + list(_DICTS.get("section_keywords", [])) + + list(_DICTS.get("abstract_keywords", [])) + + list(_DICTS.get("references", [])), + set_case_fold(TrieConfig(), True), +) + + +# --------------------------------------------------------------------------- # +# Block-clustering context bundle # +# --------------------------------------------------------------------------- # + + +class BlockClusterContext: + """Block-clustering context. Fields: j document statistics o page bbox g page statistics h lines to cluster v column rectangles """ + + __slots__ = ("tertiary_slot", "auxiliary_slot", "primary_slot", "secondary_slot", "state_slot") + + def __init__(self, doc_stats: DocStats, page_bbox: Rect, page_stats: PageStats, lines: list, columns: list): + self.tertiary_slot = doc_stats + self.auxiliary_slot = page_bbox + self.primary_slot = page_stats + self.secondary_slot = lines + self.state_slot = columns + + +# --------------------------------------------------------------------------- # +# Should a line join an existing block? # +# --------------------------------------------------------------------------- # + + +def should_join_line_to_block( + block_cluster_ctx: BlockClusterContext, + other_block: Block, + + candidate_line: Line, + + previous_line: Optional[Line], + + first_candidate_block: Block, + +) -> bool: + """Return True iff the candidate line should be appended to the current block.""" + # -- Step 1: reject incompatible skew ---------- + if abs(other_block.skew_frac() - candidate_line.skew_frac()) > 1: + return False + + # -- Step 2: size + alignment gates ---------------------------------- + font_size_delta = candidate_line.avg_font_size() - other_block.avg_font_size() + + left_edges_aligned = left_aligned(other_block, candidate_line, 1) + + both_edges_aligned = left_edges_aligned or (other_block.line_count() == 1 and left_aligned(other_block, candidate_line, 8 * avg_char_width(other_block.line()))) + + right_edges_aligned = right_aligned(other_block, candidate_line, 2) + + both_edges_aligned = both_edges_aligned and right_edges_aligned + # m = min size-excess over page body; k = min size-excess over doc body + page_body_font_delta = min(candidate_line.avg_font_size() - block_cluster_ctx.primary_slot.primary_slot, other_block.avg_font_size() - block_cluster_ctx.primary_slot.primary_slot) + + doc_body_font_delta = min(candidate_line.avg_font_size() - block_cluster_ctx.tertiary_slot.primary_slot, other_block.avg_font_size() - block_cluster_ctx.tertiary_slot.primary_slot) + + + block_last_span = last_span(last_line_of(other_block)) + + line_first_span = candidate_line.primary_slot[0] + + + if ( + abs(font_size_delta) > page_body_font_delta + and abs(font_size_delta) > doc_body_font_delta - 2 + and not (style_key(block_last_span) == style_key(line_first_span) and block_last_span.char_count() > 1 and line_first_span.char_count() > 1) + and ( + font_size_delta > 2 + or (font_size_delta > 1 and not both_edges_aligned) + or font_size_delta < -5 + or (font_size_delta < -2 and candidate_line.char_count() >= 5) + or (font_size_delta < -1 and candidate_line.char_count() >= 20 and not both_edges_aligned) + ) + ): + return False + + # -- Step 3: font / bold mismatch ------------------------------------ + block_last_line = last_line_of(other_block) + + width_ratio = magnitude_ratio(other_block.bbox_width(), candidate_line.bbox_width()) + + bold_mismatch = (block_last_span.primary_slot != line_first_span.primary_slot) + + font_mismatch = ( + block_last_span.font_name != line_first_span.font_name + and dominant_style_of(other_block) != style_key(line_first_span) + ) + + + if font_mismatch or bold_mismatch: + if bold_mismatch and width_ratio > 2: + return False + if (block_last_line.char_stats.secondary_slot == 1 or block_last_line.char_stats.secondary_slot == 2) and ( + candidate_line.char_stats.secondary_slot == 2 or width_ratio > 4 + ): + return False + if block_last_line.char_stats.tertiary_slot == 6 or other_block.bbox_width() > 1.5 * block_last_line.bbox_width(): + return False + + if other_block.bold_frac() > 0.9 and candidate_line.bold_frac() < 0.8 and width_ratio > 2: + return False + + # -- Step 4: spatial gates ------------------------------------------- + centers_aligned = center_aligned(other_block, candidate_line, 1) + + if not centers_aligned: + vertical_gap = other_block.bottom_edge() - candidate_line.top_edge() + + horizontal_offset = candidate_line.left_edge() - other_block.left_edge() + + if (vertical_gap > -1 and horizontal_offset > 0.33 * other_block.bbox_width()) or horizontal_offset > 0.98 * other_block.bbox_width(): + return False + if candidate_line.center_x() < other_block.left_edge(): + return False + + # -- Step 5: tolerance base ------------------------------------------ + bottom_edge_gap = other_block.bottom_edge() - candidate_line.bottom_edge() + + join_tolerance = ( + _max_nan_propagating(1.3 * (other_block.top_edge() - other_block.bottom_edge()) / other_block.line_count(), block_cluster_ctx.primary_slot.tertiary_slot) + + 1.3 * other_block.avg_font_size() + ) / 2.0 + + + # -- Step 6: case-flip "hanging indent" detector --------------------- + block_case_signal = case_signal(other_block.char_stats) + + line_case_signal = case_signal(candidate_line.char_stats) + + # Capture the old block-last span before comparing both sides of the case + # transition. + case_signal_flip = ( + ((block_case_signal == 1 and line_case_signal == -1) or (line_case_signal == 1 and block_case_signal == -1)) + and letter_count(candidate_line.char_stats) >= 3 + and (is_upper_dominant(other_block.char_stats) != is_upper_dominant(line_first_span.char_stats) or letter_count(line_first_span.char_stats) < 3) + and (is_upper_dominant(block_last_span.char_stats) != is_upper_dominant(candidate_line.char_stats) or letter_count(block_last_span.char_stats) < 3) + ) + + + if ( + not font_mismatch and not bold_mismatch and not case_signal_flip + and (width_ratio <= 1.2 or left_aligned(block_last_line, candidate_line, 0.1)) + # Preserve the no-guard width-ratio edge case: a zero-width block still + # allows a positive-width last line to increase the join tolerance. + and (block_last_line.bbox_width() / other_block.bbox_width() > 0.9 if other_block.bbox_width() != 0 else block_last_line.bbox_width() > 0) + ): + join_tolerance *= 1.3 + if page_body_font_delta > 0.5 * block_cluster_ctx.primary_slot.primary_slot and not case_signal_flip: + join_tolerance *= 2 + + # -- Step 7: column alignment ---------------------------------------- + column_rect = (block_cluster_ctx.state_slot[candidate_line.measure_slot] if (0 <= candidate_line.measure_slot < len(block_cluster_ctx.state_slot)) else None) or EMPTY_RECT + + line_left_aligned_to_column = left_aligned(candidate_line, column_rect, 4.5) + + line_right_aligned_to_column = right_aligned(candidate_line, column_rect, 4.5) + + block_left_aligned_to_column = left_aligned(other_block, column_rect, 4.5) + + block_right_aligned_to_column = right_aligned(other_block, column_rect, 4.5) + + block_column_justified = ( + block_left_aligned_to_column == block_right_aligned_to_column + and other_block.alignment_slot + and x_centers_close(block_cluster_ctx.auxiliary_slot, other_block) + ) + + line_column_centered = ( + line_left_aligned_to_column == line_right_aligned_to_column + and (x_centers_close(block_cluster_ctx.auxiliary_slot, candidate_line) or (block_column_justified and centers_aligned)) + ) + + + # -- Step 8: alignment multipliers ----------------------------------- + if ( + block_column_justified and line_column_centered + and other_block.bbox_width() > 0.5 * candidate_line.bbox_width() + and (previous_line is None or candidate_line.bottom_edge() - previous_line.bottom_edge() >= bottom_edge_gap) + and not font_mismatch + ): + join_tolerance *= 1.3 + if previous_line is not None and ( + (other_block.bold_frac() > previous_line.bold_frac() and candidate_line.bold_frac() > previous_line.bold_frac()) + or (other_block.avg_font_size() > previous_line.bbox_height() + 1 and candidate_line.bbox_height() > previous_line.bbox_height() + 1) + ): + join_tolerance = max(join_tolerance, candidate_line.bottom_edge() - previous_line.top_edge()) + elif block_right_aligned_to_column and line_left_aligned_to_column: + join_tolerance *= 1.3 if other_block.line_count() <= 1 else 1.2 + elif block_left_aligned_to_column and line_left_aligned_to_column: + join_tolerance *= 1.1 + elif block_right_aligned_to_column: + if other_block.line_count() <= 1: + join_tolerance *= 1.1 + if candidate_line.char_stats.secondary_slot == 3: + join_tolerance *= 1.1 + if other_block.line_count() <= 1 and candidate_line.char_stats.secondary_slot == 3: + join_tolerance *= 1.1 + if ( + candidate_line.left_edge() > other_block.left_edge() + and candidate_line.left_edge() <= other_block.left_edge() + 0.1 * other_block.bbox_width() + and (other_block.line_count() <= 1 or left_aligned(candidate_line, block_last_line, 1)) + ): + join_tolerance *= 1.2 + elif candidate_line.bbox_width() < 0.9 * block_last_line.bbox_width() and center_aligned(other_block, candidate_line, 1): + join_tolerance *= 1.1 + + if left_edges_aligned and candidate_line.bbox_width() < 0.5 * other_block.bbox_width() and other_block.char_stats.tertiary_slot != 6 and candidate_line.char_stats.tertiary_slot == 6: + join_tolerance *= 1.3 + + # -- Step 9: numbering pattern checks -------------------------------- + block_numbering_kind = numbering_kind(other_block.line()) + + block_has_numbering = ( + numbering_kind(other_block.line()) != 0 + and first_span_of(other_block).bbox_height() >= 0.8 * other_block.avg_font_size() + ) + + block_starts_with_digit = block_has_numbering and block_numbering_kind == 1 + + line_numbering_kind = numbering_kind(candidate_line) + + line_has_numbering = ( + numbering_kind(candidate_line) != 0 + and candidate_line.primary_slot[0].bbox_height() >= 0.8 * candidate_line.avg_font_size() + ) + + line_starts_with_digit = line_has_numbering and line_numbering_kind == 1 + + + if block_starts_with_digit and not line_starts_with_digit and font_size_delta <= -0.5: + join_tolerance /= 2 + elif ( + (block_starts_with_digit and (bold_mismatch or font_size_delta <= -0.5)) + or (line_starts_with_digit and (bold_mismatch or font_size_delta >= 0.5)) + ): + join_tolerance /= 1.5 + elif block_starts_with_digit and candidate_line.left_edge() >= other_block.left_edge() and 0.9 * candidate_line.bbox_width() > other_block.bbox_width(): + join_tolerance /= 1.5 + elif block_has_numbering and candidate_line.left_edge() >= other_block.left_edge() and 0.9 * candidate_line.bbox_width() > other_block.bbox_width(): + join_tolerance /= 1.3 + elif (block_starts_with_digit and candidate_line.char_stats.secondary_slot != 3 or line_starts_with_digit) and font_mismatch: + join_tolerance /= 1.3 + elif block_starts_with_digit and left_edges_aligned and candidate_line.char_stats.secondary_slot == 2: + join_tolerance /= 1.3 + elif ( + (block_has_numbering and (font_mismatch or bold_mismatch or font_size_delta <= -0.5 or (left_edges_aligned and candidate_line.char_stats.secondary_slot == 2))) + or (line_has_numbering and (font_mismatch or bold_mismatch or font_size_delta >= 0.5)) + ): + join_tolerance /= 1.1 + + if block_has_numbering and line_has_numbering: + join_tolerance /= 1.3 + + # -- Step 10: hanging-indent + neighbour patches --------------------- + block_first_letter = other_block.line().alignment_slot + + if ( + block_numbering_kind == 1 + and line_numbering_kind != 1 + and not left_edges_aligned + and block_first_letter is not None + and left_aligned(block_first_letter, candidate_line, 1) + ): + join_tolerance *= 2 + + if case_signal_flip: + join_tolerance /= 1.1 + if other_block.line_count() == 1 or not left_edges_aligned: + divisor = 3 if width_ratio > 3 else (1.5 if width_ratio > 1.5 else 1) + join_tolerance /= divisor + if (is_upper_dominant(other_block.char_stats) and block_has_numbering) or (is_upper_dominant(candidate_line.char_stats) and line_has_numbering): + join_tolerance /= 2 + if font_mismatch or bold_mismatch: + join_tolerance /= 1.5 + + if other_block is not first_candidate_block and bottom_edge_gap > 1.1 * (first_candidate_block.bottom_edge() - candidate_line.bottom_edge()): + join_tolerance /= 2 + + return bottom_edge_gap <= join_tolerance diff --git a/pageindex/flash/classification/__init__.py b/pageindex/flash/classification/__init__.py new file mode 100644 index 000000000..7d1701973 --- /dev/null +++ b/pageindex/flash/classification/__init__.py @@ -0,0 +1,131 @@ +""" +Block classification for header/footer, watermark, boilerplate, TOC-page, and +reference-list marking. The module combines recurrence hashes, page-number +patterns, body-paragraph gates, cross-page geometry, and numeric-column +clustering. The dot-leader and page-number gates intentionally use Unicode +number properties so fullwidth and non-Latin digits are handled consistently. +""" + +import json +import math +import regex as regex_module # Unicode \p{...} property classes +from pathlib import Path +from typing import Optional + +from ..model import ( + _UNICODE_WHITESPACE_CLASS, + _strip_diacritics, + _round_half_up_to_int, + magnitude_ratio, + intervals_overlap, + y_overlaps, + center_aligned, + to_number, + last_span, + heading_score, + text_of_line, + Line, + last_line_of, + first_span_of, + is_word_category, + block_text, + deaccented_text, + letter_count, + dominant_style_of, + punct_count, + info_weight, + is_upper_dominant, + is_caps_heavy, + alignment_code, + Block, +) +from ..stats import style_key, DocStats, weighted_percentile, column_index_of, char_script_bucket +from ..tokens import ( + is_trimmable_token, + token_numeric_value, + Token, + TokenView, + wrap_tokens, + enumerate_tokens, + jenkins_hash, + trie_prefix_match, + strip_trie_match, + strip_leading_if_in, + COMMA_CHARS, + strip_trailing_comma, + trim_trailing_punct, + set_case_fold, + TrieConfig, + build_trie, + LineTokenizer, + tokenize_block, + BuiltTrie, + trie_full_match, + is_char_token, + is_word_token, +) + +from .keyword_tables import ( + _DICT_PATH, + _DICTS, + _dict_trie, + COPYRIGHT_TRIE, + VOLUME_WORDS_TRIE, + TOC_TITLES_TRIE, + FIGURE_KEYWORDS_TRIE, + _TABLE_KEYWORDS_TRIE, + TABLE_KEYWORDS_TRIE, + _CHART_KEYWORDS_TRIE, + CHART_KEYWORDS_TRIE, + APPENDIX_SECTION_TRIE, + INTRODUCTION_SECTION_TRIE, + BOX_KEYWORD_TRIE, + KEYWORDS_SECTION_TRIE, + _BOILERPLATE_PHRASES_PATH, + BOILERPLATE_TRIE, + DOT_LEADER_ROW_RE, + PAGE_NUMBER_ONLY_RE, + _search_trie, + _normalize_text_key, +) +from .body_text import ( + record_recurring_text, + is_body_paragraph, + span_style_text_key, + normalized_block_text, + _ROMAN_NUMERALS, + span_page_number, + longest_word_and_number, +) +from .header_footer import ( + PageMarkState, + record_marked_block, + is_header_positioned, + has_adjacent_page_numbers, + mark_header_footer, + walk_from_page_edge, + find_cross_page_match, + HeaderFooterContext, + bounded_edit_distance, + detect_header_footer, +) +from .toc_boilerplate import ( + mark_watermarks, + _institution_thesis_words, + INSTITUTION_THESIS_TRIE, + PROFESSOR_TITLES_TRIE, + is_boilerplate_block, + NumberColumnCluster, + extract_number_column, + pick_nearer_cluster, + detect_toc_range, + mark_toc_and_boilerplate, +) + +__all__ = [ + "is_body_paragraph", "record_recurring_text", "span_style_text_key", "normalized_block_text", "span_page_number", "longest_word_and_number", "record_marked_block", "PageMarkState", "is_header_positioned", "has_adjacent_page_numbers", "mark_header_footer", "walk_from_page_edge", "find_cross_page_match", + "HeaderFooterContext", "detect_header_footer", "mark_watermarks", + "is_boilerplate_block", "NumberColumnCluster", "extract_number_column", "pick_nearer_cluster", "detect_toc_range", "mark_toc_and_boilerplate", + "bounded_edit_distance", + "COPYRIGHT_TRIE", "VOLUME_WORDS_TRIE", "TOC_TITLES_TRIE", "FIGURE_KEYWORDS_TRIE", "TABLE_KEYWORDS_TRIE", "CHART_KEYWORDS_TRIE", "APPENDIX_SECTION_TRIE", "INTRODUCTION_SECTION_TRIE", "BOX_KEYWORD_TRIE", "KEYWORDS_SECTION_TRIE", +] diff --git a/pageindex/flash/classification/body_text.py b/pageindex/flash/classification/body_text.py new file mode 100644 index 000000000..6eb1871e6 --- /dev/null +++ b/pageindex/flash/classification/body_text.py @@ -0,0 +1,209 @@ +"""Body-paragraph classification and recurring-text recording.""" + +from __future__ import annotations + +import math +from typing import Optional + +from ..model import ( + _UNICODE_WHITESPACE_CLASS, + _strip_diacritics, + _round_half_up_to_int, + magnitude_ratio, + intervals_overlap, + y_overlaps, + center_aligned, + to_number, + last_span, + heading_score, + text_of_line, + Line, + last_line_of, + first_span_of, + is_word_category, + block_text, + deaccented_text, + letter_count, + dominant_style_of, + punct_count, + info_weight, + is_upper_dominant, + is_caps_heavy, + alignment_code, + Block, +) +from ..stats import style_key, DocStats, weighted_percentile, column_index_of, char_script_bucket +from ..tokens import ( + is_trimmable_token, + token_numeric_value, + Token, + TokenView, + wrap_tokens, + enumerate_tokens, + jenkins_hash, + trie_prefix_match, + strip_trie_match, + strip_leading_if_in, + COMMA_CHARS, + strip_trailing_comma, + trim_trailing_punct, + set_case_fold, + TrieConfig, + build_trie, + LineTokenizer, + tokenize_block, + BuiltTrie, + trie_full_match, + is_char_token, + is_word_token, +) + +from .keyword_tables import ( + BOILERPLATE_TRIE, + PAGE_NUMBER_ONLY_RE, + _normalize_text_key, +) + + +# --------------------------------------------------------------------------- # +# Recurring-text histogram updater # +# --------------------------------------------------------------------------- # + + +def record_recurring_text(doc, other_text: str) -> None: + """Increment the recurring-text histogram under the Jenkins lookup2 hash key. Empty normalized text is a valid key and must not be skipped.""" + key = jenkins_hash(other_text) + doc.tertiary_slot[key] = doc.tertiary_slot.get(key, 0) + 1 + + +# --------------------------------------------------------------------------- # +# Body-paragraph predicate # +# --------------------------------------------------------------------------- # + + +# The phrase gate is intentionally narrow. Broad Latin keyword matching +# over-rejects normal body paragraphs, for example sentences starting with +# "figure", and then lets figure captions be treated as headings. + + +def is_body_paragraph(doc_stats: DocStats, page, block: Block) -> bool: + """Return whether ``block`` is a substantive body paragraph.""" + if block.weighted_ratio_primary < 0.6: + return False + width = info_weight(block.char_stats) + lines = block.line_count() + sentence_punct = block.char_stats.primary_slot[6] + + # The width-per-line ratio uses IEEE-style division. For a zero-line block, + # d/e is +inf + # (d>0) or NaN (d==0), so every ``d/e < k`` test is False and the block is + # NOT rejected here (it falls through to the char-count gate below, which + # rejects an empty block). This is not an early return. + dw_per_line = ( + width / lines if lines != 0 + else (math.inf if width > 0 else math.nan) + ) + if ( + dw_per_line < 15 + or (lines >= 10 and dw_per_line < 20) + or (lines >= 10 and dw_per_line < 25 and sentence_punct < lines / 8) + or (lines >= 20 and dw_per_line < 40 and sentence_punct < lines / 20) + ): + return False + + block_width = block.bbox_width() + if lines >= 4: + short = 0 + for state_item in block: + if state_item.bbox_width() < 0.75 * block_width and not state_item.primary_slot[0].state_slot.startswith("•"): + short += 1 + if short >= lines / 2 and sentence_punct < lines / 8: + return False + + if block_width < page.bounds.bbox_width() / 7: + return False + + chars = block.char_count() + if chars < 40 or (lines >= 3 and alignment_code(block) == 3) or letter_count(block.char_stats) < 0.1 * chars: + return False + + size = block.avg_font_size() + body_size = min(page.primary_slot.primary_slot, doc_stats.primary_slot) + min_value = min(doc_stats.primary_slot, max(page.bounds.bbox_height(), page.bounds.bbox_width()) / 60) + min_value = min(0.7 * min_value, min_value - 3) + # Boilerplate phrases are rejected as non-body even when they otherwise look + # paragraph-like. This keeps acknowledgement/copyright/proceedings language + # out of body-density calculations without broad keyword matching. + + if size < body_size - 2 or size < min_value or trie_prefix_match(BOILERPLATE_TRIE, tokenize_block(block)): + return False + + if chars >= 250 and lines >= 4: + return True + if block_width < page.bounds.bbox_width() / 5 or size < body_size - 0.5: + return False + if chars >= 100 and lines >= 2 and sentence_punct >= 2: + return True + if (chars >= 100 or block.char_stats.tertiary_slot == 6) and ( + size >= page.primary_slot.primary_slot - 0.5 or size > doc_stats.primary_slot - 0.1 + ): + return first_span_of(block).font_name == page.primary_slot.state_slot or last_span(last_line_of(block)).font_name == page.primary_slot.state_slot + return False + + +# --------------------------------------------------------------------------- # +# Header/footer helper keys and predicates # +# --------------------------------------------------------------------------- # + + +def span_style_text_key(span) -> str: + """Span style hash including text content: font name, rounded height, bold flag, lowercase text.""" + # Use exact half-up integer rounding; Python f"{x:.0f}" uses half-even. + return f"{span.font_name} {_round_half_up_to_int(span.bbox_height())} {'B' if span.primary_slot else 'R'} {span.text.lower()}" + + +def normalized_block_text(block: Block) -> str: + """block normalized-text hash.""" + out = [] + for token in tokenize_block(block): + out.append(_normalize_text_key(token.str.lower())) + return "".join(out) + + +# Roman numeral lookup used for page-number-like header/footer spans. +_ROMAN_NUMERALS = { + "I": 1, "II": 2, "III": 3, "IV": 4, "V": 5, "VI": 6, "VII": 7, + "VIII": 8, "IX": 9, "X": 10, "XI": 11, "XII": 12, "XIII": 13, + "XIV": 14, "XV": 15, "XVI": 16, "XVII": 17, "XVIII": 18, "XIX": 19, "XX": 20, +} + + +def span_page_number(span) -> Optional[int]: + """Extract a page number from a span using a digit gate, then Roman numeral lookup.""" + text = span.text + match = PAGE_NUMBER_ONLY_RE.match(text) + if match: + page_number = to_number(match.group(1)) + if not math.isnan(page_number) and page_number > 0 and page_number < 1e6 and page_number == math.ceil(page_number): + return int(page_number) + return None + return _ROMAN_NUMERALS.get(text.upper()) + + +def longest_word_and_number(block: Block) -> list[str]: + """extract longest letter-word and longest digit-string. Returns a list of 0-2 strings: lowercased longest word (if >3 chars), then the longest digit-string (raw). """ + longest_word: Optional[str] = None + longest_number: Optional[str] = None + for tok in tokenize_block(block): + if tok.type == 2: + if longest_word is None or len(tok.str) > len(longest_word): + longest_word = tok.str + elif tok.type == 1: + if longest_number is None or len(tok.str) > len(longest_number): + longest_number = tok.str + out: list[str] = [] + if longest_word and len(longest_word) > 3: + out.append(_normalize_text_key(longest_word.lower())) + if longest_number: + out.append(longest_number) + return out diff --git a/pageindex/flash/classification/header_footer.py b/pageindex/flash/classification/header_footer.py new file mode 100644 index 000000000..dcf2c4ca4 --- /dev/null +++ b/pageindex/flash/classification/header_footer.py @@ -0,0 +1,481 @@ +"""Header and footer detection via cross-page recurrence.""" + +from __future__ import annotations + +import math +from typing import Optional + +from ..model import ( + _UNICODE_WHITESPACE_CLASS, + _strip_diacritics, + _round_half_up_to_int, + magnitude_ratio, + intervals_overlap, + y_overlaps, + center_aligned, + to_number, + last_span, + heading_score, + text_of_line, + Line, + last_line_of, + first_span_of, + is_word_category, + block_text, + deaccented_text, + letter_count, + dominant_style_of, + punct_count, + info_weight, + is_upper_dominant, + is_caps_heavy, + alignment_code, + Block, +) +from ..stats import style_key, DocStats, weighted_percentile, column_index_of, char_script_bucket +from ..tokens import ( + is_trimmable_token, + token_numeric_value, + Token, + TokenView, + wrap_tokens, + enumerate_tokens, + jenkins_hash, + trie_prefix_match, + strip_trie_match, + strip_leading_if_in, + COMMA_CHARS, + strip_trailing_comma, + trim_trailing_punct, + set_case_fold, + TrieConfig, + build_trie, + LineTokenizer, + tokenize_block, + BuiltTrie, + trie_full_match, + is_char_token, + is_word_token, +) + +from .keyword_tables import ( + COPYRIGHT_TRIE, + VOLUME_WORDS_TRIE, + FIGURE_KEYWORDS_TRIE, + TABLE_KEYWORDS_TRIE, + CHART_KEYWORDS_TRIE, + _search_trie, +) +from .body_text import ( + record_recurring_text, + is_body_paragraph, + span_style_text_key, + normalized_block_text, + span_page_number, + longest_word_and_number, +) + + +class PageMarkState: + """Per-page classification state: first classified index, max heading score, and classified character count.""" + + __slots__ = ("primary_slot", "secondary_slot", "tertiary_slot") + + def __init__(self): + self.primary_slot = -1 + self.secondary_slot = 0 + self.tertiary_slot = 0 + + +def record_marked_block(state: PageMarkState, idx: int, block: Block) -> None: + """Update per-page state after classifying ``block``.""" + state.primary_slot = idx + state.secondary_slot = max(state.secondary_slot, heading_score(block)) + state.tertiary_slot += block.char_count() + + +def is_header_positioned(ctx, other_block: Block, candidate_block: Optional[Block]) -> bool: + """Return whether a block is header-positioned relative to the reference block, with content-density gates.""" + if candidate_block is None: + cond = True + elif ctx.primary_slot == 1: + cond = other_block.top_edge() > candidate_block.bottom_edge() + else: + cond = other_block.bottom_edge() < candidate_block.top_edge() + return cond and other_block.line_count() == 1 and info_weight(other_block.char_stats) >= 8 and letter_count(other_block.char_stats) >= 5 and other_block.char_stats.primary_slot[1] >= 1 + + +def has_adjacent_page_numbers(ctx, page: int, candidate_number: int, reference_flag: bool) -> bool: + """Return whether nearby pages show a strong ``n±1`` / ``n±2`` / ``n±4`` page-number pattern.""" + page_index = page - 1 + page_count = len(ctx.secondary_slot.primary_slot) + adjacent_one = ( + (page_index - 1 >= 0 and (candidate_number - 1) in ctx.tertiary_slot[page_index - 1]) + or (page_index + 1 < page_count and (candidate_number + 1) in ctx.tertiary_slot[page_index + 1]) + ) + adjacent_two = ( + (page_index - 2 >= 0 and (candidate_number - 2) in ctx.tertiary_slot[page_index - 2]) + or (page_index + 2 < page_count and (candidate_number + 2) in ctx.tertiary_slot[page_index + 2]) + ) + if not adjacent_one and not adjacent_two: + return False + if adjacent_one and adjacent_two: + return True + adjacent_four = ( + (page_index - 4 >= 0 and (candidate_number - 4) in ctx.tertiary_slot[page_index - 4]) + or (page_index + 4 < page_count and (candidate_number + 4) in ctx.tertiary_slot[page_index + 4]) + ) + if candidate_number > page / 2 - 30: + return adjacent_one or (not reference_flag and adjacent_two) or (adjacent_two and adjacent_four) + return bool(adjacent_two and adjacent_four) + + +def mark_header_footer(ctx, other_block: Block) -> None: + """mark block as classified + bump ghost-text count.""" + record_recurring_text(ctx.secondary_slot, deaccented_text(other_block)) + other_block.type = ctx.primary_slot + + +def walk_from_page_edge(ctx, blocks: list[Block], callback) -> None: + """direction-aware iteration. HEADER (g=1) walks blocks in normal order from top; FOOTER (g=2) walks in reverse from bottom. ``callback`` returns True to halt. """ + if ctx.primary_slot == 1: + for page in blocks: + if callback(page): + break + else: + block_index = len(blocks) - 1 + while block_index >= 0: + if callback(blocks[block_index]): + break + block_index -= 1 + + +def find_cross_page_match(ctx, page, block: Block, text_key: str, ref: Block) -> Optional[Block]: + """Find a matching block on a nearby page by exact normalized text, then by longest word/number pieces.""" + entries = ctx.auxiliary_slot.get(text_key) or [] + for entry in entries: + entry_page_index = entry["page_index"] + entry_block: Block = entry["block"] + if entry_page_index < page.page_index - 3: + continue + if entry_page_index == page.page_index: + continue + if entry_page_index > page.page_index + 3: + break + distance_sq = entry_block.left_edge() - block.left_edge() + left_delta = entry_block.top_edge() - block.top_edge() + right_delta = entry_block.right_edge() - block.right_edge() + bottom_delta = entry_block.bottom_edge() - block.bottom_edge() + distance_sq = distance_sq * distance_sq + left_delta * left_delta + right_delta * right_delta + bottom_delta * bottom_delta + size = page.primary_slot.primary_slot + if not ( + distance_sq >= 100 + or (distance_sq >= 1 and ( + (page.page_index == 1 and heading_score(block) >= size + 0.5) + or (entry_page_index == 1 and heading_score(entry_block) >= size + 0.5) + )) + ): + return entry_block + + if is_header_positioned(ctx, block, ref): + for key in longest_word_and_number(block): + map_value = ctx.measure_slot.get(key) + if map_value is None or len(map_value) < max(4, len(ctx.secondary_slot.primary_slot) / 4): + continue + target = heading_score(block) + for nearby_page_index in range(page.page_index - 2, page.page_index + 3): + if nearby_page_index == page.page_index: + continue + nearby_entry = map_value.get(nearby_page_index) + if nearby_entry is None: + continue + body_font_size = page.primary_slot.primary_slot + if (abs(target - heading_score(nearby_entry["block"])) > 1 + or (page.page_index == 1 and target >= body_font_size + 0.5) + or (nearby_page_index == 1 and heading_score(nearby_entry["block"]) >= body_font_size + 0.5)): + continue + threshold = min(len(text_key), len(nearby_entry["text_key"])) / 5 + if bounded_edit_distance(text_key, nearby_entry["text_key"], threshold) >= threshold: + continue + return nearby_entry["block"] + return None + + +# --------------------------------------------------------------------------- # +# Header/footer detection context # +# --------------------------------------------------------------------------- # + + +class HeaderFooterContext: + """Per-pass header/footer state.""" + + __slots__ = ("secondary_slot", "primary_slot", "previous_slot", "option_slot", "tertiary_slot", "auxiliary_slot", "measure_slot", "state_slot") + + def __init__(self, doc, candidate_number: int): + self.secondary_slot = doc + self.primary_slot = candidate_number + self.previous_slot = "HEADER" if candidate_number == 1 else "FOOTER" + self.option_slot: dict[str, int] = {} # span style/text key -> page count + self.tertiary_slot: list[set[int]] = [] # per-page page-number set + self.auxiliary_slot: dict[str, list[dict]] = {} # normalized text key -> location/block records + self.measure_slot: dict[str, dict[int, dict]] = {} # word/number key -> page -> text/block record + self.state_slot: list[list[Block]] = [] # per-page candidate blocks + + +# --------------------------------------------------------------------------- # +# Bounded edit distance for fuzzy block-key comparison. # +# --------------------------------------------------------------------------- # + + +def bounded_edit_distance(text: str, other_text: str, candidate_item: float) -> float: + """Bounded banded edit distance. Returns the limit when the strings differ by more than that many edits; otherwise returns the exact Levenshtein distance.""" + candidate_item = max(len(text), len(other_text)) if candidate_item <= 0 else math.ceil(candidate_item) + if len(text) <= 0: + return min(len(other_text), candidate_item) + if len(other_text) <= 0: + return min(len(text), candidate_item) + if len(text) < len(other_text): + text, other_text = other_text, text # a is the longer string (columns) + if len(text) - len(other_text) >= candidate_item: + return candidate_item + reference_item = 0 # leftmost band column + entry_item = 0 # rightmost band column + score_value = [0] * (len(text) + 1) # previous row + group_value = [0] * (len(text) + 1) # current row + for state_item in range(len(text) + 1): # seed row 0, but only out to column c + score_value[state_item] = state_item + if state_item > candidate_item: + break + entry_item = state_item + for state_item in range(1, len(other_text) + 1): + compare_char = other_text[state_item - 1] + key_value = len(text) # leftmost column kept < c this row + measure_item = 0 # rightmost column kept < c this row + for line_value in range(reference_item, min(entry_item + 1, len(text)) + 1): + if line_value == reference_item: + group_value[line_value] = 1 + score_value[line_value] + elif text[line_value - 1] == compare_char: + group_value[line_value] = score_value[line_value - 1] + else: + group_value[line_value] = 1 + min(group_value[line_value - 1], score_value[line_value - 1]) + if line_value <= entry_item: + group_value[line_value] = min(group_value[line_value], 1 + score_value[line_value]) + if group_value[line_value] < candidate_item: + key_value = min(key_value, line_value) + measure_item = line_value + if key_value > measure_item: # whole band reached c -> distance >= c + return candidate_item + score_value, group_value = group_value, score_value + reference_item = key_value + entry_item = measure_item + return min(score_value[entry_item] + len(text) - entry_item, candidate_item) + + +# --------------------------------------------------------------------------- # +# Header / footer detection # +# --------------------------------------------------------------------------- # + + +def detect_header_footer(ctx: HeaderFooterContext) -> None: + """Run the three-pass header/footer detector.""" + # ----- Pass 1: per-page candidate collection ----------------------- + for page in ctx.secondary_slot.primary_slot: + seen_style_keys: set[str] = set() + page_numbers: set[int] = set() + ctx.tertiary_slot.append(page_numbers) + page_candidates: list[Block] = [] + ctx.state_slot.append(page_candidates) + first_substantive_ref: list[Optional[Block]] = [None] # closure-friendly + + def walk_cb(block: Block) -> bool: + if block.skew_frac() >= 1 or block.area() <= 0: + return False + # Page height should be positive. If a degenerate page appears, keep + # IEEE-style Infinity/NaN behavior so the comparisons below stay inert. + den = page.bounds.bbox_height() + num = block.top_edge() if ctx.primary_slot == 1 else block.bottom_edge() + relative = (num / den) if den else (math.copysign(math.inf, num) if num else math.nan) + if (ctx.primary_slot == 1 and relative < 0.8) or (ctx.primary_slot == 2 and relative > 0.2): + pass_value = False + else: + tokens = tokenize_block(block) + if _search_trie(COPYRIGHT_TRIE, tokens): + pass_value = True + elif ( + block.line_count() >= 3 + or info_weight(block.char_stats) * (1 + block.bold_frac()) >= 200 + or trie_prefix_match(FIGURE_KEYWORDS_TRIE, tokens) + or trie_prefix_match(CHART_KEYWORDS_TRIE, tokens) + or trie_prefix_match(TABLE_KEYWORDS_TRIE, tokens) + ): + pass_value = False + else: + pass_value = True + if not pass_value: + return True + if letter_count(block.char_stats) >= 5 and first_substantive_ref[0] is None: + first_substantive_ref[0] = block + ref = first_substantive_ref[0] + if block.type == 0 and block.char_count() > 0: + page_candidates.append(block) + if letter_count(block.char_stats) >= 5: + text_key = normalized_block_text(block) + item_list = ctx.auxiliary_slot.get(text_key) + if item_list is None: + item_list = [] + ctx.auxiliary_slot[text_key] = item_list + item_list.append({"page_index": page.page_index, "block": block}) + if is_header_positioned(ctx, block, ref): + for key in longest_word_and_number(block): + inner = ctx.measure_slot.get(key) + if inner is None: + inner = {} + ctx.measure_slot[key] = inner + if page.page_index not in inner: + inner[page.page_index] = {"text_key": text_key, "block": block} + for line in block: + for span in line: + if span.char_count() <= 0: + continue + ok = span_style_text_key(span) + if block.char_count() >= 4 and ok not in seen_style_keys: + ctx.option_slot[ok] = ctx.option_slot.get(ok, 0) + 1 + seen_style_keys.add(ok) + detected_page_number = span_page_number(span) + if detected_page_number is not None: + page_numbers.add(detected_page_number) + return False + + walk_from_page_edge(ctx, page.output_slot, walk_cb) + + # ----- Pass 2: per-page rejection sweep ---------------------------- + text_counts: dict[str, int] = {} + samples: list[tuple[float, float]] = [] + + for page in ctx.secondary_slot.primary_slot: + candidates = ctx.state_slot[page.page_index - 1] + state = PageMarkState() + seen_page_number = False + first_substantive: list[Optional[Block]] = [None] + for candidate_index in range(len(candidates)): + candidate_block = candidates[candidate_index] + if candidate_block.char_count() <= 0: + continue + if candidate_block.type == ctx.primary_slot: + record_marked_block(state, candidate_index, candidate_block) + continue + if candidate_block.type != 0: + continue + candidate_tokens = tokenize_block(candidate_block) + # Copyright terms must appear at the start of the block, not merely + # anywhere inside it. + if candidate_tokens.length < 10 and trie_prefix_match(COPYRIGHT_TRIE, candidate_tokens): + mark_header_footer(ctx, candidate_block) + record_marked_block(state, candidate_index, candidate_block) + continue + if letter_count(candidate_block.char_stats) >= 5: + if first_substantive[0] is None: + first_substantive[0] = candidate_block + pk_hash = normalized_block_text(candidate_block) + match = find_cross_page_match(ctx, page, candidate_block, pk_hash, first_substantive[0]) + if match is not None: + mark_header_footer(ctx, candidate_block) + record_marked_block(state, candidate_index, candidate_block) + other_unmarked = match.type != ctx.primary_slot + if other_unmarked: + mark_header_footer(ctx, match) + if len(pk_hash) >= 5: + previous_count = text_counts.get(pk_hash, 0) + text_counts[pk_hash] = 2 if (previous_count or other_unmarked) else 1 + continue + style_threshold = max(2.0, min(len(ctx.secondary_slot.primary_slot) / 3.0, 5.0)) + chars = 0 + for candidate_line in candidate_block: + for candidate_span in candidate_line: + if candidate_span.char_count() <= 0: + continue + style_hash = span_style_text_key(candidate_span) + if ctx.option_slot.get(style_hash, 0) >= style_threshold: + chars += candidate_span.char_count() + continue + page_number = span_page_number(candidate_span) + if page_number is not None and has_adjacent_page_numbers(ctx, page.page_index, page_number, seen_page_number): + seen_page_number = True + chars += candidate_span.char_count() + if chars >= candidate_block.char_count(): + mark_header_footer(ctx, candidate_block) + record_marked_block(state, candidate_index, candidate_block) + pk_again = normalized_block_text(candidate_block) + if len(pk_again) >= 5: + text_counts[pk_again] = text_counts.get(pk_again, 0) + 1 + + if state.primary_slot < 0: + continue + first_block = candidates[state.primary_slot] + samples.append((first_block.center_y(), float(state.tertiary_slot))) + + # Also classify earlier non-confirmed blocks + for index in range(state.primary_slot): + block = candidates[index] + if block.type == ctx.primary_slot: + continue + if ctx.primary_slot == 1 and block.bottom_edge() < first_block.bottom_edge(): + continue + if block.bbox_width() >= page.bounds.bbox_width() / 2: + continue + if is_body_paragraph(ctx.secondary_slot.secondary_slot, page, block): + continue + if letter_count(block.char_stats) > 0 and heading_score(block) >= state.secondary_slot + 1: + continue + block.type = ctx.primary_slot + record_recurring_text(ctx.secondary_slot, deaccented_text(block)) + + # ----- Pass 3: cutoff line + top-3 text-hash sweep ----------------- + if len(samples) < len(ctx.secondary_slot.primary_slot) / 20: + return + cutoff = weighted_percentile(samples, 20 if ctx.primary_slot == 1 else 80) + + top: list[tuple[str, int]] = [] + for text_hash, count in text_counts.items(): + if count < len(ctx.secondary_slot.primary_slot) / 20: + continue + top.append((text_hash, count)) + if not top: + return + top.sort(key=lambda item_pair: -item_pair[1]) + if len(top) > 3: + top = top[:3] + + for page in ctx.secondary_slot.primary_slot: + for recurring_block in ctx.state_slot[page.page_index - 1]: + if ctx.primary_slot == 1 and recurring_block.top_edge() < cutoff: + break + if ctx.primary_slot == 2 and recurring_block.bottom_edge() > cutoff: + break + if recurring_block.type != 0: + continue + recurring_tokens = tokenize_block(recurring_block) + stripped = _search_trie(VOLUME_WORDS_TRIE, recurring_tokens) + if stripped is not None: + # ``stripped.end`` is absolute in the forward token view, so this + # drops the matched volume phrase and keeps the tail. + tail = recurring_tokens.slice(stripped.end) + head_tok = tail.token_at(0) if tail.length > 0 else None + if head_tok is not None and head_tok.type == 1: + recurring_block.type = ctx.primary_slot + record_recurring_text(ctx.secondary_slot, deaccented_text(recurring_block)) + # Deliberately fall through: the same block can also match the + # top recurring-text sweep below. + if letter_count(recurring_block.char_stats) < 5: + continue + if page.page_index <= 1 and heading_score(recurring_block) > ctx.secondary_slot.secondary_slot.primary_slot + 1: + continue + text_key = normalized_block_text(recurring_block) + for text_hash, _ in top: + threshold = min(len(text_key), len(text_hash)) / 2.0 + if bounded_edit_distance(text_key, text_hash, threshold) >= threshold: + continue + # No break: every sufficiently similar recurring key contributes + # to the recurring-text histogram. + recurring_block.type = ctx.primary_slot + record_recurring_text(ctx.secondary_slot, deaccented_text(recurring_block)) diff --git a/pageindex/flash/classification/keyword_tables.py b/pageindex/flash/classification/keyword_tables.py new file mode 100644 index 000000000..62f2fe6fc --- /dev/null +++ b/pageindex/flash/classification/keyword_tables.py @@ -0,0 +1,113 @@ +"""Dictionary-backed keyword tries and shared regexes.""" + +from __future__ import annotations + +import json +import regex as regex_module # Unicode \p{...} property classes +from pathlib import Path +from typing import Optional + +from ..model import ( + _UNICODE_WHITESPACE_CLASS, + _strip_diacritics, + _round_half_up_to_int, + magnitude_ratio, + intervals_overlap, + y_overlaps, + center_aligned, + to_number, + last_span, + heading_score, + text_of_line, + Line, + last_line_of, + first_span_of, + is_word_category, + block_text, + deaccented_text, + letter_count, + dominant_style_of, + punct_count, + info_weight, + is_upper_dominant, + is_caps_heavy, + alignment_code, + Block, +) +from ..tokens import ( + is_trimmable_token, + token_numeric_value, + Token, + TokenView, + wrap_tokens, + enumerate_tokens, + jenkins_hash, + trie_prefix_match, + strip_trie_match, + strip_leading_if_in, + COMMA_CHARS, + strip_trailing_comma, + trim_trailing_punct, + set_case_fold, + TrieConfig, + build_trie, + LineTokenizer, + tokenize_block, + BuiltTrie, + trie_full_match, + is_char_token, + is_word_token, +) + + +# --------------------------------------------------------------------------- # +# Load dictionaries (built into tries on first use) # +# --------------------------------------------------------------------------- # + + +_DICT_PATH = Path(__file__).parent.parent / "data" / "dictionaries.json" +_DICTS = json.loads(_DICT_PATH.read_text(encoding="utf-8")) + + +def _dict_trie(key: str) -> BuiltTrie: + """Build a case-folded trie from a dictionary entry.""" + return build_trie(_DICTS.get(key, []), set_case_fold(TrieConfig(), True)) + + +COPYRIGHT_TRIE = build_trie(["Copyright", "©"], set_case_fold(TrieConfig(), True)) # inline list +VOLUME_WORDS_TRIE = _dict_trie("volume_words") +TOC_TITLES_TRIE = _dict_trie("toc_titles") +FIGURE_KEYWORDS_TRIE = _dict_trie("ai_section_keywords") +_TABLE_KEYWORDS_TRIE = _dict_trie("table_keywords") +TABLE_KEYWORDS_TRIE = _TABLE_KEYWORDS_TRIE + +_CHART_KEYWORDS_TRIE = _dict_trie("chart_keywords") +CHART_KEYWORDS_TRIE = _CHART_KEYWORDS_TRIE +APPENDIX_SECTION_TRIE = _dict_trie("appendices_dict") +INTRODUCTION_SECTION_TRIE = _dict_trie("introduction_dict") +BOX_KEYWORD_TRIE = build_trie(["box"], set_case_fold(TrieConfig(), True)) # inline list +KEYWORDS_SECTION_TRIE = _dict_trie("keywords_dict") + +# Multilingual boilerplate phrase trie: publisher and proceeding headers plus +# stock acknowledgement openers such as "First of all I would like to thank". +# Used by the body-paragraph gate to reject boilerplate as non-body. +# Phrase list stored as a data asset. +_BOILERPLATE_PHRASES_PATH = Path(__file__).parent.parent / "data" / "boilerplate_phrases.json" +BOILERPLATE_TRIE = build_trie(json.loads(_BOILERPLATE_PHRASES_PATH.read_text(encoding="utf-8")), set_case_fold(TrieConfig(), True)) + +# Regular expressions for the dot-leader and page-number gates (Unicode \p{Number} -> ``regex`` module). +# Leading class is ASCII 1-9 + fullwidth 1-9 (U+FF11-FF19); it must NOT admit +# fullwidth zero U+FF10, so it is [1-91-9], not [1-90-9]. +DOT_LEADER_ROW_RE = regex_module.compile(r"([.][" + _UNICODE_WHITESPACE_CLASS + r"]*){5,}[" + _UNICODE_WHITESPACE_CLASS + r"]*[1-91-9]\p{Number}*\Z") +PAGE_NUMBER_ONLY_RE = regex_module.compile(r"^[ |]*([1-91-9]\p{Number}*)[ |]*\Z") + + +def _search_trie(trie: BuiltTrie, tokens) -> Optional[TokenView]: + """Return the shortest earliest Aho-Corasick trie match for ``tokens``.""" + from ..tokens import aho_corasick_tokens as _real_bh + return _real_bh(trie, tokens) + + +def _normalize_text_key(text: str) -> str: + """Strip diacritics only; callers lowercase first when a case-folded key is needed.""" + return _strip_diacritics(text) diff --git a/pageindex/flash/classification/toc_boilerplate.py b/pageindex/flash/classification/toc_boilerplate.py new file mode 100644 index 000000000..714b7279e --- /dev/null +++ b/pageindex/flash/classification/toc_boilerplate.py @@ -0,0 +1,375 @@ +"""Watermark, boilerplate, and TOC-range detection.""" + +from __future__ import annotations + +import math +from typing import Optional + +from ..model import ( + _UNICODE_WHITESPACE_CLASS, + _strip_diacritics, + _round_half_up_to_int, + magnitude_ratio, + intervals_overlap, + y_overlaps, + center_aligned, + to_number, + last_span, + heading_score, + text_of_line, + Line, + last_line_of, + first_span_of, + is_word_category, + block_text, + deaccented_text, + letter_count, + dominant_style_of, + punct_count, + info_weight, + is_upper_dominant, + is_caps_heavy, + alignment_code, + Block, +) +from ..tokens import ( + is_trimmable_token, + token_numeric_value, + Token, + TokenView, + wrap_tokens, + enumerate_tokens, + jenkins_hash, + trie_prefix_match, + strip_trie_match, + strip_leading_if_in, + COMMA_CHARS, + strip_trailing_comma, + trim_trailing_punct, + set_case_fold, + TrieConfig, + build_trie, + LineTokenizer, + tokenize_block, + BuiltTrie, + trie_full_match, + is_char_token, + is_word_token, +) + +from .keyword_tables import ( + _DICTS, + _dict_trie, + TOC_TITLES_TRIE, + DOT_LEADER_ROW_RE, + _search_trie, +) +from .body_text import ( + is_body_paragraph, + normalized_block_text, +) + + +# --------------------------------------------------------------------------- # +# Side-rail watermark detector # +# --------------------------------------------------------------------------- # + + +def mark_watermarks(doc) -> None: + """Bucket skewed side-rail blocks by normalized text; recurring groups are marked as watermarks.""" + buckets: dict[str, list[Block]] = {} + for page in doc.primary_slot: + for block in page.output_slot: + if block.skew_frac() < 1: + continue + if letter_count(block.char_stats) < 5: + continue + # Skip blocks in the central 80% of the page width. + horizontal_offset = block.center_x() + page_width = page.bounds.bbox_width() + if 0.1 * page_width < horizontal_offset < 0.9 * page_width: + continue + # No empty-string guard: empty normalized-text keys bucket together. + key = normalized_block_text(block) + buckets.setdefault(key, []).append(block) + for group in buckets.values(): + if len(group) < 3: + continue + for block in group: + block.type = 12 + + +# --------------------------------------------------------------------------- # +# Boilerplate block predicate # +# --------------------------------------------------------------------------- # + + +# Institution/thesis trie combines institution words with thesis-specific terms. +_institution_thesis_words = list(_DICTS.get("institution_words", [])) + list(_DICTS.get("nk_thesis_words", [])) +INSTITUTION_THESIS_TRIE = build_trie(_institution_thesis_words, set_case_fold(TrieConfig(), True)) +PROFESSOR_TITLES_TRIE = _dict_trie("professor_titles") + + +def is_boilerplate_block(block: Block) -> bool: + """line/block looks like boilerplate (committee members, author affiliations, journal volume info etc.).""" + tokens = tokenize_block(block) + if info_weight(block.char_stats) >= 200 or tokens.length >= 100: + return False + if _search_trie(INSTITUTION_THESIS_TRIE, tokens): + return True + # Strip leading lines that match professor/title boilerplate. + while tokens.length > 0: + # Count tokens belonging to the first token's line and strip that line. + first_line = tokens.token_at(0).line() if tokens.token_at(0) else None + if first_line is None: + break + line_end = 0 + while line_end < tokens.length: + tok = tokens.token_at(line_end) + if tok is None or tok.line() is not first_line: + break + line_end += 1 + if not trie_prefix_match(PROFESSOR_TITLES_TRIE, tokens.slice(0, line_end)): + return False + tokens = tokens.slice(line_end) + return True + + +# --------------------------------------------------------------------------- # +# TOC-page detection chain # +# --------------------------------------------------------------------------- # + + +class NumberColumnCluster: + """numeric-leading-token cluster.""" + + __slots__ = ("anchor_x", "width", "secondary_slot", "primary_slot", "length", "tertiary_slot") + + def __init__(self, anchor_x_value: float, width: float, reference_number: int, next_number: int, length: int, limit_flag: bool): + self.anchor_x = anchor_x_value # anchor x-position + self.width = width # cluster typical width + self.secondary_slot = reference_number # first value seen + self.primary_slot = next_number # last value seen + self.length = length + self.tertiary_slot = limit_flag # is increasing + + +def extract_number_column(block) -> Optional[NumberColumnCluster]: + """Extract a numeric-leading cluster if block lines form an increasing page-number sequence.""" + column = 0 + last_number = 0 + sequence_length = 0 + for line in block: + line_number = to_number(text_of_line(line)) + if math.isnan(line_number): + return None + if not (line_number > 0 and line_number < 1e6 and line_number == math.ceil(line_number)) or line_number >= 1e4 or last_number > line_number: + return NumberColumnCluster(block.center_x(), block.bbox_width(), column, last_number, sequence_length, False) + if column <= 0: + column = int(line_number) + last_number = int(line_number) + sequence_length += 1 + return NumberColumnCluster(block.center_x(), block.bbox_width(), column, last_number, sequence_length, True) + + +def pick_nearer_cluster(cluster: NumberColumnCluster, other_cluster: Optional[NumberColumnCluster], other: Optional[NumberColumnCluster]) -> Optional[NumberColumnCluster]: + """Pick the closer neighbor cluster within the current cluster width.""" + distance = (cluster.anchor_x - other_cluster.anchor_x) if other_cluster is not None else math.inf + candidate_distance = (other.anchor_x - cluster.anchor_x) if other is not None else math.inf + if distance > cluster.width and candidate_distance > cluster.width: + return None + return other_cluster if distance < candidate_distance else other + + +def detect_toc_range(doc, page, index) -> Optional[dict]: + """Detect a TOC-like block range within ``page``. The detector combines dot-leader rows, blocks ending in dot-leader page numbers, contents-like titles, and same-x-range numeric clusters. Returns a ``{start_index, end_index}`` range or ``None``.""" + blocks = page.output_slot + lines = 0 + dot_leader_blocks = 0 + weight = 0.0 + last_multiline = -1 + contents = -1 + pre_contents = -1 + last_toc = -1 + seen_body = False + body_stop_y = page.bounds.top_edge() + is_last_page = (index == page.page_index - 1) if isinstance(index, int) and index >= 0 else False + clusters: list[NumberColumnCluster] = [] # sorted by anchor_x + + def _add_cluster(cluster: NumberColumnCluster) -> None: + # Sorted-set semantics: an equal anchor_x is a no-op. + import bisect + keys = [existing_cluster.anchor_x for existing_cluster in clusters] + insert_index = bisect.bisect_left(keys, cluster.anchor_x) + if insert_index < len(clusters) and clusters[insert_index].anchor_x == cluster.anchor_x: + return + clusters.insert(insert_index, cluster) + + def _next_number_column_cluster(cluster: NumberColumnCluster) -> Optional[NumberColumnCluster]: + # Non-strict successor: an equal anchor_x entry is returned. + import bisect + keys = [column.anchor_x for column in clusters] + cluster_index = bisect.bisect_left(keys, cluster.anchor_x) + return clusters[cluster_index] if cluster_index < len(clusters) else None + + def _prev_number_column_cluster(cluster: NumberColumnCluster) -> Optional[NumberColumnCluster]: + # Non-strict predecessor: an equal anchor_x entry is returned. + import bisect + keys = [column.anchor_x for column in clusters] + cluster_index = bisect.bisect_right(keys, cluster.anchor_x) + return clusters[cluster_index - 1] if cluster_index > 0 else None + + def _remove(cluster: NumberColumnCluster) -> None: + try: + clusters.remove(cluster) + except ValueError: + pass + + for codepoint, block in enumerate(blocks): + is_toc = False + # Count dot-leader rows across all lines in the block. + + for line in block: + if DOT_LEADER_ROW_RE.search(text_of_line(line)): + is_toc = True + if last_toc >= 0: + last_toc = codepoint + else: + lines += 1 + if lines >= 5 or (lines >= 3 and is_last_page): + last_toc = codepoint + # A dot-leader on the last line also starts or extends the TOC range. + + last_line = block.primary_slot[-1] if block.primary_slot else None + if last_line is not None and DOT_LEADER_ROW_RE.search(text_of_line(last_line)): + is_toc = True + if last_toc >= 0: + last_toc = codepoint + continue + else: + dot_leader_blocks += 1 + weight += info_weight(block.char_stats) + if is_last_page and dot_leader_blocks >= 2 and weight >= 0.8 * page.primary_slot.secondary_slot: + last_toc = codepoint + # Body block tracking + if not is_toc and is_body_paragraph(doc.secondary_slot, page, block): + seen_body = True + body_stop_y = min(body_stop_y, block.bottom_edge()) + if block.line_count() > 1 and not is_toc: + last_multiline = codepoint + # "Contents"-like title must consume the whole block, not just a prefix. + if contents < 0 and block.line_count() <= 1 and trie_full_match(TOC_TITLES_TRIE, tokenize_block(block)): + contents = codepoint + pre_contents = last_multiline + if not seen_body and block.top_edge() > 3 * page.bounds.bbox_height() / 4: + return {"start_index": pre_contents + 1, "end_index": len(blocks) - 1} + # Numeric-column clustering on unclassified blocks below the body line. + if block.right_edge() < page.bounds.center_x(): + continue + if block.top_edge() > body_stop_y: + continue + # Extract even from an empty-looking block; the extractor decides whether + # a usable numeric sequence exists. + cluster = extract_number_column(block) + if cluster is not None: + successor = _next_number_column_cluster(cluster) + predecessor = _prev_number_column_cluster(cluster) + picked = pick_nearer_cluster(cluster, predecessor, successor) + if picked is not None: + _remove(picked) + new_value = NumberColumnCluster( + picked.anchor_x, + picked.width, + picked.secondary_slot, + cluster.primary_slot, + picked.length + cluster.length, + picked.tertiary_slot and cluster.tertiary_slot and picked.primary_slot <= cluster.secondary_slot, + ) + else: + new_value = cluster + _add_cluster(new_value) + if (new_value.tertiary_slot + and (new_value.length >= 10 or (new_value.length >= 5 and is_last_page)) + and new_value.primary_slot - new_value.secondary_slot > 0.01 * new_value.primary_slot): + last_toc = codepoint + + if last_toc < 0: + return None + return {"start_index": pre_contents + 1 if pre_contents >= 0 else 0, "end_index": last_toc} + + +# --------------------------------------------------------------------------- # +# TOC pages, references lists, and figure/table captions # +# --------------------------------------------------------------------------- # + + +def mark_toc_and_boilerplate(doc) -> None: + """Mark TOC blocks as type=9 and captions/boilerplate as type=12.""" + previous_toc_page = -math.inf + for page in doc.primary_slot: + if ( + page.page_index - 1 >= len(doc.primary_slot) / 2 + and page.primary_slot.secondary_slot >= 0.9 * doc.secondary_slot.secondary_slot + ): + continue + # "Most-boilerplate" check for front-matter pages. + + if ( + page.page_index > 1 + and page.page_index < 50 + and page.primary_slot.secondary_slot < max(200, min(0.75 * doc.secondary_slot.secondary_slot, 1000)) + ): + total = 0.0 + body_paragraph = 0.0 + body_paragraph_lines = 0 + for line_or_block in page.output_slot: + if line_or_block.type != 0 or line_or_block.skew_frac() >= 1: + continue + width_value = info_weight(line_or_block.char_stats) * heading_score(line_or_block) + total += width_value + if is_boilerplate_block(line_or_block): + body_paragraph += width_value + body_paragraph_lines += 1 + if body_paragraph >= 0.8 * total and body_paragraph_lines >= 3: + for block in page.output_slot: + block.type = 12 + continue + toc = detect_toc_range(doc, page, previous_toc_page) + if toc is None: + continue + previous_toc_page = page.page_index + start = toc["start_index"] + end = toc["end_index"] + blocks = page.output_slot + # Track centered-block count, weighted font sum, total weight, and the + # running bottom edge used by walk-forward break conditions. + centered_flag = 0 + width_flag = 0.0 + width = 0.0 + walk_break_y = page.bounds.top_edge() + for idx in range(start, len(blocks)): + block = blocks[idx] + score = heading_score(block) + if idx <= end: + if block.isolated_centered: + centered_flag += 1 + heading_weight = info_weight(block.char_stats) + width_flag += block.avg_font_size() * heading_weight + width += heading_weight + walk_break_y = min(walk_break_y, block.bottom_edge()) + block.type = 9 + continue + # Walk forward with four break conditions: prominent heading, centered + # block, dense body text, or a large vertical gap to a prominent block. + if block.skew_frac() < 1 and score > doc.secondary_slot.primary_slot + 4 and score > page.primary_slot.primary_slot + 4: + break + if centered_flag <= 1 and block.isolated_centered: + break + if info_weight(block.char_stats) > 300 and block.char_stats.primary_slot[6] > 2 and block.weighted_ratio_secondary > 0.5: + break + if width > 0: + average = width_flag / width + if walk_break_y - block.top_edge() > average and block.skew_frac() < 1 and score > average + 1.5: + break + walk_break_y = min(walk_break_y, block.bottom_edge()) + block.type = 9 diff --git a/pageindex/flash/clustering/__init__.py b/pageindex/flash/clustering/__init__.py new file mode 100644 index 000000000..2839a629a --- /dev/null +++ b/pageindex/flash/clustering/__init__.py @@ -0,0 +1,68 @@ +"""Line clustering pipeline. + +The initial pass walks spans in document order and groups them into lines using +an in-line continuation test, while also collapsing overstrike duplicates +(artificial-bold rendering where the same glyph is painted twice). The merge +pass inserts lines into a sorted structure keyed by top-desc reading order, +looks up predecessor/successor neighbors, and either merges the new line into a +neighbor or keeps it separate. Neighbor lookup is inclusive of an exact +reading-order key match, so the successor uses ``bisect_left`` and the +predecessor uses ``bisect_right - 1``. +""" + +import re +from dataclasses import dataclass, field +from typing import Optional + +from sortedcontainers import SortedKeyList + +from ..model import ( + _UNICODE_WHITESPACE_CLASS, + avg_char_width2, + Span, + magnitude_ratio, + same_x_extent, + same_y_extent, + append_span, + last_span, + avg_char_width, + raw_text_of_line, + text_of_line, + reading_order_key, + left_edge_key, + numbering_kind, + Line, + letter_count, + is_upper_dominant, +) + +from .merge_rules import ( + TRAILING_DOT_LEADER_RE, + span_continues_line, + vertical_distance_in_line_heights, + pick_closer_neighbor, + should_merge_lines, +) +from .build import ( + _skip_mark_only, + build_initial_lines, + _is_label_stack, + LinesContainer, + _set_add, + cluster_lines, +) + +# --------------------------------------------------------------------------- # +# Combined helper # +# --------------------------------------------------------------------------- # + + +__all__ = [ + "span_continues_line", + "vertical_distance_in_line_heights", + "pick_closer_neighbor", + "should_merge_lines", + "build_initial_lines", + "cluster_lines", + "TRAILING_DOT_LEADER_RE", +] diff --git a/pageindex/flash/clustering/build.py b/pageindex/flash/clustering/build.py new file mode 100644 index 000000000..3abf07c9c --- /dev/null +++ b/pageindex/flash/clustering/build.py @@ -0,0 +1,211 @@ +"""Builds initial lines and clusters them into merged lines.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Optional + +from sortedcontainers import SortedKeyList + +from ..model import ( + _UNICODE_WHITESPACE_CLASS, + avg_char_width2, + Span, + magnitude_ratio, + same_x_extent, + same_y_extent, + append_span, + last_span, + avg_char_width, + raw_text_of_line, + text_of_line, + reading_order_key, + left_edge_key, + numbering_kind, + Line, + letter_count, + is_upper_dominant, +) + +from .merge_rules import ( + span_continues_line, + pick_closer_neighbor, + should_merge_lines, +) + + +# --------------------------------------------------------------------------- # +# Initial line builder. +# --------------------------------------------------------------------------- # + + +def _skip_mark_only(span: Span, page_area: float) -> bool: + """Return True for mark-heavy tiny glyphs whose area is below one part per million of the page area.""" + return (span.char_count() - span.char_stats.primary_slot[5]) > 1 and span.area() < page_area * 1e-6 + + +def build_initial_lines(spans: list[Span], page_bbox) -> list[Line]: + """Build initial lines from flat spans. Returns the list of initial lines. """ + line: list[Line] = [] + pending_line = Line() + pending_span: Optional[Span] = None + page_area = page_bbox.area() + + for span in spans: + if span.text == "" or _skip_mark_only(span, page_area): + continue + if pending_span is not None: + # Overstrike duplicate detection: same trimmed text, both edges + + # both top/bottom within 10% of f's geometry -> f gets the bold + # bit and h is discarded. + if ( + pending_span.char_count() > 0 + and pending_span.state_slot == span.state_slot + and same_x_extent(pending_span, span, 0.1 * pending_span.bbox_width()) + and same_y_extent(pending_span, span, 0.1 * pending_span.bbox_height()) + ): + pending_span.primary_slot = True + continue + # End current line if e is non-empty AND tn says NOT to continue + if not (len(pending_line.primary_slot) <= 0 or span_continues_line(pending_line, pending_span)): + line.append(pending_line) + pending_line = Line() + append_span(pending_line, pending_span) + pending_span = span + else: + pending_span = span + + if pending_span is not None: + if not (len(pending_line.primary_slot) <= 0 or span_continues_line(pending_line, pending_span)): + line.append(pending_line) + pending_line = Line() + append_span(pending_line, pending_span) + line.append(pending_line) + # If no current span exists, the pending line is intentionally dropped. + # This path is currently unreachable from the loop logic. + return line + + +# --------------------------------------------------------------------------- # +# xn -- line clustering driver # +# --------------------------------------------------------------------------- # + + +def _is_label_stack(line: Line, other_line: Line, body_ma: float) -> bool: + """Detect a display-sized label stacked directly above the text it labels. The geometry must overlap horizontally while sitting on a different baseline; the upper piece must be display-sized relative to body text and larger than the lower text. This captures chapter numbers and drop caps that should be read before the title below them.""" + if body_ma <= 0: + return False + overlap = min(line.right_edge(), other_line.right_edge()) - max(line.left_edge(), other_line.left_edge()) + frac = overlap / max(1e-6, min(line.bbox_width(), other_line.bbox_width())) + vertical_overlap = min(line.top_edge(), other_line.top_edge()) - max(line.bottom_edge(), other_line.bottom_edge()) + vertical_overlap_fraction = vertical_overlap / max(1e-6, min(line.bbox_height(), other_line.bbox_height())) + if not (frac > 0.5 and vertical_overlap_fraction < 0.5): + return False + upper, lower = (line, other_line) if line.center_y() > other_line.center_y() else (other_line, line) + # display-type (>= 2x body) AND larger than the text it sits above + # (>= 1.5x lower): a leading label over smaller text. The second clause + # drops same-size display stacks (e.g. chart axis numbers over each other). + return upper.avg_font_size() >= 2.0 * body_ma and upper.avg_font_size() >= 1.5 * lower.avg_font_size() + + +@dataclass +class LinesContainer: + """Mutable line container used by the clustering pass.""" + + primary_slot: list[Line] = field(default_factory=list) + + +def _set_add(tree: SortedKeyList, line: Line) -> None: + """Set-style insertion into the sorted line index. Lines with identical top, bottom, left, and right ordering keys are dropped instead of duplicated.""" + idx = tree.bisect_left(line) + if idx < len(tree) and reading_order_key(tree[idx]) == reading_order_key(line): # type: ignore[arg-type] + return # reading-order key collision -> sorted set insertion drops the element + tree.add(line) + + +def cluster_lines(lines_container: LinesContainer, other_item: float, candidate_items: list) -> list[Line]: + """Mutate the contained line list by merging nearby compatible lines.""" + # Sort input lines by reading order. + lines_container.primary_slot.sort(key=left_edge_key) + + # Body-text reference for the display-size test in _is_label_stack: the + # median glyph font size across the page (dominated by body text). + merged_accent_spans = sorted( + span_value.font_size for line in lines_container.primary_slot for span_value in line.primary_slot + if getattr(span_value, "font_size", 0) > 0 + ) + body_ma = merged_accent_spans[len(merged_accent_spans) // 2] if merged_accent_spans else 0.0 + + # Tree of lines, ordered by reading position (top desc, bottom desc, left, right). + tree: SortedKeyList = SortedKeyList(key=reading_order_key) + merged_lines: list[Line] = [] # output (lines that won't merge further) + + for candidate_line in lines_container.primary_slot: + # Rotated / skewed lines: don't try to cluster, just emit + if last_span(candidate_line).previous_slot > 1: + merged_lines.append(candidate_line) + continue + + # successor (just below f vertically) and predecessor (just above). + # predecessor/successor search are INCLUSIVE floor/ceiling, so a reading-order-key-equal line already + # in the tree is the zero-distance neighbour: successor = bisect_left + # (first key >= f), predecessor = bisect_right - 1 (last key <= f). + idx_succ = tree.bisect_left(candidate_line) + successor_line = tree[idx_succ] if idx_succ < len(tree) else None + idx_pred = tree.bisect_right(candidate_line) + line_item = tree[idx_pred - 1] if idx_pred > 0 else None + + neighbor_line = pick_closer_neighbor(line_item, successor_line, candidate_line, other_item) + if neighbor_line is None: + _set_add(tree, candidate_line) + continue + + tree.remove(neighbor_line) + neighbor_last_span = last_span(neighbor_line) # last span of k + + # Subscript / overstrike case (single-span f duplicating k's last span) + if ( + len(candidate_line.primary_slot) == 1 + and len(neighbor_line.primary_slot) <= 5 + and neighbor_last_span.char_count() > 0 + and neighbor_last_span.state_slot == candidate_line.primary_slot[0].state_slot + and same_x_extent(neighbor_last_span, candidate_line, 0.1 * neighbor_last_span.bbox_width()) + and same_y_extent(neighbor_last_span, candidate_line, 0.1 * neighbor_last_span.bbox_height()) + ): + if abs(neighbor_last_span.left_edge() - candidate_line.left_edge()) < 0.01 and abs(neighbor_last_span.top_edge() - candidate_line.top_edge()) < 0.01: + # exact duplicate -> keep the original line unchanged + _set_add(tree, neighbor_line) + continue + # Otherwise create a new line carrying k's spans with m marked bold + new_line = Line() + neighbor_last_span.primary_slot = True + for source_span in neighbor_line: + append_span(new_line, source_span) + _set_add(tree, new_line) + elif should_merge_lines(neighbor_line, candidate_line, candidate_items): + # Continuation merge. Normally append f after k (left-to-right). + # If the candidate is a display-sized label stacked above the text, + # reading order is top-to-bottom, so the label leads. Reorder spans + # only; the merge and block/line structure stay unchanged. + if _is_label_stack(neighbor_line, candidate_line, body_ma) and candidate_line.center_y() > neighbor_line.center_y(): + merged = Line() + for span in candidate_line: + append_span(merged, span) + for span in neighbor_line: + append_span(merged, span) + _set_add(tree, merged) + else: + for span in candidate_line: + append_span(neighbor_line, span) + _set_add(tree, neighbor_line) + else: + # Cannot merge: emit k as a finalized line, start fresh with f + merged_lines.append(neighbor_line) + _set_add(tree, candidate_line) + + # Drain remaining + merged_lines.extend(tree) + # Final sort by reading order. + merged_lines.sort(key=reading_order_key) + lines_container.primary_slot = merged_lines + return merged_lines diff --git a/pageindex/flash/clustering/merge_rules.py b/pageindex/flash/clustering/merge_rules.py new file mode 100644 index 000000000..b807a5a17 --- /dev/null +++ b/pageindex/flash/clustering/merge_rules.py @@ -0,0 +1,190 @@ +"""Span continuation and line-merge predicates.""" + +from __future__ import annotations + +import re +from typing import Optional + +from ..model import ( + _UNICODE_WHITESPACE_CLASS, + avg_char_width2, + Span, + magnitude_ratio, + same_x_extent, + same_y_extent, + append_span, + last_span, + avg_char_width, + raw_text_of_line, + text_of_line, + reading_order_key, + left_edge_key, + numbering_kind, + Line, + letter_count, + is_upper_dominant, +) + + +# Matches "...." dot-leader trails used in TOC entries: "Chapter 1 ........" +TRAILING_DOT_LEADER_RE = re.compile(r"([.][" + _UNICODE_WHITESPACE_CLASS + r"]*){4,}\Z") + + +# --------------------------------------------------------------------------- # +# In-line continuation predicate. +# --------------------------------------------------------------------------- # + + +def span_continues_line(line: Line, other_span: Span) -> bool: + """Return whether ``span`` continues the current line. The test requires matching skew, overlapping vertical intervals, and a horizontal gap within a per-character tolerance that widens after sentence-ending punctuation.""" + if last_span(line).previous_slot != other_span.previous_slot: + return False + line_center_y = line.center_y() # a's y-center + span_center_y = other_span.center_y() # b's y-center + # Vertical disjointness check: if both centers fall outside the other box, + # the spans are not on the same line. + if (line_center_y > other_span.top_edge() or line_center_y < other_span.bottom_edge()) and (span_center_y > line.top_edge() or span_center_y < line.bottom_edge()): + return False + # tolerance from per-char height + tolerance = min(5.0, max(0.1, avg_char_width(line), avg_char_width2(other_span))) + wide_tolerance = 2.0 * tolerance + # When a's last char is sentence-end punctuation, widen the tolerance + if line.char_stats.tertiary_slot == 5: + wide_tolerance *= 2.0 + return other_span.left_edge() > line.right_edge() - wide_tolerance and other_span.left_edge() < line.right_edge() + tolerance + + +# --------------------------------------------------------------------------- # +# Neighbor distance and picker. +# --------------------------------------------------------------------------- # + + +def vertical_distance_in_line_heights(line: Line, other_line: Line) -> float: + """normalized vertical-center distance between two lines. ``|a.center_y - b.center_y| / max(a.bbox_height, b.bbox_height)``: how many line-heights apart the centres are. Returns 0 when centres coincide. """ + line_center_y = line.center_y() + other_center_y = other_line.center_y() + if line_center_y == other_center_y: + return 0.0 + denom = max(line.bbox_height(), other_line.bbox_height()) + if denom == 0.0: + # Empty lines carry an inverted-sentinel bbox. Preserve IEEE division + # edge cases so the later distance comparison simply does not merge. + diff = line_center_y - other_center_y + return float("nan") if diff != diff else float("inf") + return abs(line_center_y - other_center_y) / denom + + +def pick_closer_neighbor( + line: Optional[Line], + other_line: Optional[Line], + candidate_line: Line, + reference_item: float, +) -> Optional[Line]: + """Pick the closer neighboring line to the current line when it falls within the merge tolerance. Returns the closer candidate when the distance is below the threshold, else ``None``. Either or both candidates may be ``None`` (e.g. c is at the top of the tree -> no predecessor). """ + if line is None and other_line is None: + return None + entry_item = vertical_distance_in_line_heights(line, candidate_line) if line is not None else float("inf") + second_candidate = vertical_distance_in_line_heights(other_line, candidate_line) if other_line is not None else float("inf") + if entry_item >= reference_item and second_candidate >= reference_item: + return None + return line if entry_item < second_candidate else other_line + + +# --------------------------------------------------------------------------- # +# Line merge predicate. +# --------------------------------------------------------------------------- # + + +def should_merge_lines(line: Line, other_line: Line, candidate_items: list) -> bool: + """Return whether ``other_line`` should merge into ``line``. The decision compares the horizontal gap against a tolerance based on harmonic mean character width, then adjusts for style mismatch, script category, dot leaders, column membership, short continuations, bracketed starts, sentence endings, and uppercase dominance.""" + if line.char_count() > 0 and other_line.char_count() > 0: + # Different skew/rotation -> never merge + if magnitude_ratio(line.previous_slot, other_line.previous_slot) > 2 and abs(line.previous_slot - other_line.previous_slot) > 10: + return False + + # Harmonic mean of character heights with no clamp. A zero char-height + # contributes an infinite inverse, driving the merge tolerance to zero. + line_projection = 1.0 / avg_char_width(line) if avg_char_width(line) != 0 else float("inf") + other_projection = 1.0 / avg_char_width(other_line) if avg_char_width(other_line) != 0 else float("inf") + harmonic_char_width = 2.0 / (line_projection + other_projection) + horizontal_gap = other_line.left_edge() - line.right_edge() # horizontal gap + gap_factor = 2.0 + + # italic mismatch + italic = line.bold_frac() > 0 + other_italic = other_line.bold_frac() > 0 + if italic != other_italic: + gap_factor /= 1.5 + + # last-char category 4 = other-letter (Lo, CJK/syllabics) + # OR more than half of a's chars are category 4 + if line.char_stats.tertiary_slot == 4 or line.char_stats.primary_slot[4] > line.char_count() / 2: + gap_factor /= 2.0 + + # sentence-end + all-digits + dot leader pattern -> TOC row, don't merge + sent_end = line.char_stats.tertiary_slot == 6 + if sent_end: + # candidate numeric-token test: the candidate has digits and all characters are digits + + all_digits = other_line.char_stats.auxiliary_slot > 0 and other_line.char_stats.auxiliary_slot == other_line.char_stats.primary_slot[1] + if all_digits and TRAILING_DOT_LEADER_RE.search(raw_text_of_line(line)): + gap_factor *= 3.0 + else: + all_digits = False + + # Column-based bonuses ---------------------------------------------------- + if candidate_items and 0 <= line.measure_slot < len(candidate_items): + line_column = candidate_items[line.measure_slot] + col_left = line_column.get("left", float("inf")) + col_right = line_column.get("right", float("-inf")) + else: + col_left = float("inf") + col_right = float("-inf") + + inside_col = ( + line.left_edge() >= col_left + and line.right_edge() <= col_right + and other_line.left_edge() >= col_left + and other_line.right_edge() <= col_right + ) + if (line.char_count() < 40 or inside_col) and ( + same_y_extent(line, other_line, 0.1) or same_y_extent(last_span(line), other_line, 0.1) + ): + gap_factor *= 1.5 + if line.char_count() < 40 and inside_col: + gap_factor *= 2.0 + + # At-column-edge demotion + if 0 <= other_line.measure_slot < len(candidate_items): + other_column = candidate_items[other_line.measure_slot] + else: + other_column = None + if ( + len(candidate_items) <= 0 + or ( + abs(line.right_edge() - col_right) < 5 + and (line.measure_slot >= len(candidate_items) - 1 or not other_column or abs(other_line.left_edge() - other_column.get("left", float("inf"))) < 5) + ) + ): + gap_factor /= 2.0 + + # Very short leading line with continuation evidence: short, low aspect, + # numbering-like, and followed by text with letters. The inside-column flag + # controls whether this gets the stronger multiplier. + if line.char_count() <= 8 and line.bbox_width() <= 10 * line.avg_font_size() and numbering_kind(line) != 0 and letter_count(other_line.char_stats) > 0: + gap_factor *= 3.0 if inside_col else 2.0 + + # Bracketed short line or uppercase sentence-period inside a column. + if line.char_count() <= 10: + text = text_of_line(line) + if text.startswith("[") and text.endswith("]"): + gap_factor *= 2.0 + elif inside_col and line.char_stats.secondary_slot == 2 and text.endswith("."): + gap_factor *= 2.0 + + # Both lines are uppercase-dominant inside the same column. + + if inside_col and is_upper_dominant(line.char_stats) and is_upper_dominant(other_line.char_stats): + gap_factor *= 1.5 + + return horizontal_gap <= gap_factor * harmonic_char_width diff --git a/pageindex/flash/columns/__init__.py b/pageindex/flash/columns/__init__.py new file mode 100644 index 000000000..c46663f3f --- /dev/null +++ b/pageindex/flash/columns/__init__.py @@ -0,0 +1,47 @@ +""" +Column detection via sweep-line gutter scoring and recursive page splitting. + +The detector builds horizontal and vertical sweep events, scores candidate +gutters, assigns column indexes to lines, and returns column rectangles used by +the second line-clustering pass. Direction code 0 scans vertical positions to +find row breaks; direction code 1 scans horizontal positions to find column +breaks. +""" + +import math +from typing import Optional + +from ..model import Rect, rect_union, EMPTY_RECT, Line, info_weight, text_of_line, numbering_kind, numbering_value, _UNICODE_WHITESPACE_CLASS, _max_nan_propagating + + +# Detect TOC dot leaders ("... 5", "....3"). Gutter scoring rejects a split +# candidate when too many dot-leader lines straddle the gap, because a TOC page +# should remain in one reading region. +# The regular expression is end-anchored only; use re.search rather than re.match. +import re as re_module + +from .gutters import ( + SweepEvent, + SplitCandidate, + ColumnDetectionContext, + DOT_LEADER_RE, + collect_gutter_candidates, + _score_gutter_gap, +) +from .splitting import ( + assign_column_index, + recursive_split, + detect_columns, + columns_to_x_bounds, +) + +__all__ = [ + "SweepEvent", + "SplitCandidate", + "ColumnDetectionContext", + "collect_gutter_candidates", + "assign_column_index", + "recursive_split", + "detect_columns", + "columns_to_x_bounds", +] diff --git a/pageindex/flash/columns/gutters.py b/pageindex/flash/columns/gutters.py new file mode 100644 index 000000000..94b7d04e6 --- /dev/null +++ b/pageindex/flash/columns/gutters.py @@ -0,0 +1,343 @@ +"""Gutter-gap candidates and scoring for column detection.""" + +from __future__ import annotations + +import math +from typing import Optional + +from ..model import Rect, rect_union, EMPTY_RECT, Line, info_weight, text_of_line, numbering_kind, numbering_value, _UNICODE_WHITESPACE_CLASS, _max_nan_propagating + + +# Detect TOC dot leaders ("... 5", "....3"). Gutter scoring rejects a split +# candidate when too many dot-leader lines straddle the gap, because a TOC page +# should remain in one reading region. +# The regular expression is end-anchored only; use re.search rather than re.match. +import re as re_module + + +# --------------------------------------------------------------------------- # +# Sweep event. ``is_start=True`` means "line enters" at a start edge; False means +# "line leaves" at an end edge. +# --------------------------------------------------------------------------- # + + +class SweepEvent: + __slots__ = ("line", "position", "is_start") + + def __init__(self, line: Line, position: float, is_start_flag: bool): + self.line = line + self.position = position + self.is_start = is_start_flag + + +# --------------------------------------------------------------------------- # +# Column-split candidate. Direction 0 is a vertical sweep; direction 1 is a +# horizontal sweep. Higher score is better. +# --------------------------------------------------------------------------- # + + +class SplitCandidate: + __slots__ = ("start", "end", "direction", "score") + + def __init__(self, start: float, end: float, direction_value: int, score: float): + self.start = start + self.end = end + self.direction = direction_value + self.score = score + + +# --------------------------------------------------------------------------- # +# Detection context. Thresholds derived from page geometry and page statistics. +# --------------------------------------------------------------------------- # + + +class ColumnDetectionContext: + """Page-level thresholds used while recursively scoring gutter candidates.""" + + __slots__ = ("secondary_slot", "primary_slot", "tertiary_slot", "state_slot", "auxiliary_slot", "option_slot", "measure_slot") + + def __init__(self, primary_item, secondary_item, candidate_item): + self.secondary_slot = secondary_item + self.primary_slot = candidate_item + # ``log2(0)`` would be -inf -- guard against empty input. + self.tertiary_slot = math.floor(2 * math.log2(len(candidate_item))) if candidate_item else 0 + self.state_slot = primary_item.bbox_width() / 6.0 + self.auxiliary_slot = max(0.5 * secondary_item.primary_slot, min(1.1 * (secondary_item.tertiary_slot - secondary_item.primary_slot), 3.0 * secondary_item.primary_slot)) + self.option_slot = secondary_item.measure_slot + self.measure_slot = 1.5 * secondary_item.primary_slot +DOT_LEADER_RE = re_module.compile(r"([.][" + _UNICODE_WHITESPACE_CLASS + r"]*){5,}\Z") + + +# --------------------------------------------------------------------------- # +# Score split candidates in a sweep. +# --------------------------------------------------------------------------- # + + +def collect_gutter_candidates( + context: ColumnDetectionContext, + other_rect: Rect, # root rect (page bbox) + candidate_rect: Rect, # current sub-rect + events: list[SweepEvent], # sorted events + direction: int, # direction: 0 vert sweep / 1 horiz sweep + extent: float, # extent (height or width) + min_gap: float, # minimum gutter size + out_candidates: list[SplitCandidate], # output: candidates to append to +) -> None: + """Walk adjacent event pairs looking for column gutters. Each candidate gap receives a multiplicative score from line weight, font-size balance, indent/outdent structure, citation markers, and edge proximity; the best viable score wins.""" + active_count = 0 + for size_value in range(len(events) - 1): + if events[size_value].is_start: + active_count += 1 + else: + active_count -= 1 + if active_count > 0: + continue + # The gap between adjacent event positions is a candidate gutter. + score = _score_gutter_gap(context, other_rect, candidate_rect, events, direction, extent, min_gap, size_value) + if score is not None: + key_value = events[size_value].position + score_value = events[size_value + 1].position + out_candidates.append(SplitCandidate(key_value, score_value, direction, score)) + + +def _score_gutter_gap( + primary_item: ColumnDetectionContext, + other_rect: Rect, # root rect + candidate_rect: Rect, # current sub-rect (variable name p follows the extraction rule) + + reference_items: list[SweepEvent], # events + next_number: int, # direction + extent: float, # extent + min_gap: float, # min gutter + limit_number: int, # current event index +) -> Optional[float]: + """Score one candidate gap at adjacent sweep events, or return None when it is not viable.""" + key_value = reference_items[limit_number].position + score_value = reference_items[limit_number + 1].position + item_value = score_value - key_value + if item_value < min_gap: + return None + + # --- backward pass: lines that close before this gap ----------------- + measure_item = preceding_max_width = 0 + secondary_item = reference_item = distance_accumulator = width_value = wide_accumulator = 0.0 + min_edge = math.inf + preceding_max_trailing_edge = -math.inf + min_edge_position = math.inf + max_edge = -math.inf + event_count = 0 + lower_accumulator = math.inf + candidate_item = group_value = max_char_count = state_item = 0 + sample_item = limit_number + while sample_item >= 0: + sweep_event = reference_items[sample_item] + event_position = sweep_event.position + event_is_start = sweep_event.is_start + sweep_line = sweep_event.line + if event_position < key_value - item_value: + break + if event_is_start: + sample_item -= 1 + continue + measure_item += 1 + preceding_max_width = max(preceding_max_width, sweep_line.bbox_width()) + if next_number == 1: + leading_edge, trailing_edge = sweep_line.bottom_edge(), sweep_line.top_edge() + else: + leading_edge, trailing_edge = sweep_line.left_edge(), sweep_line.right_edge() + min_edge = min(min_edge, leading_edge) + preceding_max_trailing_edge = max(preceding_max_trailing_edge, trailing_edge) + entry_item = info_weight(sweep_line.char_stats) + secondary_item += entry_item + if sweep_line.avg_font_size() > reference_item: + reference_item = sweep_line.avg_font_size() + distance_accumulator = entry_item + elif sweep_line.avg_font_size() == reference_item: + distance_accumulator += entry_item + if key_value - event_position < 1: + width_value += 1 + wide_accumulator = max(wide_accumulator, sweep_line.bbox_width()) + min_edge_position = min(min_edge_position, leading_edge) + max_edge = max(max_edge, trailing_edge) + if numbering_kind(sweep_line) == 1: + event_count += 1 + if numbering_value(sweep_line) == 1: + lower_accumulator = min(lower_accumulator, sweep_line.left_edge()) + if next_number == 1: + if sweep_line.char_count() <= 5 and numbering_kind(sweep_line) != 0: + candidate_item += 1 + if sweep_line.char_count() <= 10: + text = text_of_line(sweep_line) + if (text.startswith("[") and text.endswith("]")) or ( + sweep_line.char_stats.secondary_slot == 2 and text.endswith(".") + ): + group_value += 1 + if DOT_LEADER_RE.search(text_of_line(sweep_line)): + state_item += 1 + max_char_count = max(max_char_count, sweep_line.char_count()) + sample_item -= 1 + + # Sanity gates + if ( + candidate_item >= measure_item + or candidate_item >= max(2, measure_item / 2) + or group_value >= measure_item + or state_item >= max(2, measure_item / 2) + or (next_number == 1 and max_char_count <= 1) + ): + return None + + # --- forward pass: lines that open after this gap -------------------- + next_gap = 0.0 + following_line_count = 0 + other_gap = math.inf + following_max_trailing_edge = -math.inf + page_gap = following_max_font_size = after = 0 + quantity = 0 + numbering_score = following_numbering_count = following_edge_max_width = 0 + right_gap = 0.0 + count_item = limit_number + 1 + while count_item < len(reference_items): + sweep_event = reference_items[count_item] + event_position = sweep_event.position + event_is_start = sweep_event.is_start + sweep_line = sweep_event.line + if event_position > score_value + item_value: + break + if not event_is_start: + count_item += 1 + continue + following_line_count += 1 + next_gap = max(next_gap, sweep_line.bbox_width()) + if next_number == 1: + leading_edge, trailing_edge = sweep_line.bottom_edge(), sweep_line.top_edge() + else: + leading_edge, trailing_edge = sweep_line.left_edge(), sweep_line.right_edge() + other_gap = min(other_gap, leading_edge) + following_max_trailing_edge = max(following_max_trailing_edge, trailing_edge) + width = info_weight(sweep_line.char_stats) + after += width + if sweep_line.avg_font_size() > following_max_font_size: + following_max_font_size = sweep_line.avg_font_size() + page_gap = width + elif sweep_line.avg_font_size() == following_max_font_size: + page_gap += width + if event_position - score_value < 1: + quantity += 1 + following_edge_max_width = max(following_edge_max_width, sweep_line.bbox_width()) + if numbering_kind(sweep_line) == 1: + following_numbering_count += 1 + numbering_score = _max_nan_propagating(numbering_score, numbering_value(sweep_line)) + right_gap = _max_nan_propagating(right_gap, sweep_line.top_edge()) + count_item += 1 + + if measure_item <= 0 or following_line_count <= 0: + return None + + # Combined scoring mixes the root page rectangle for page-level thresholds + # with the current recursive sub-rectangle for split geometry. Root and sub + # coincide before the first split, but diverge on genuinely multi-column + # pages; keeping both frames is part of the column decision model. + root_width = other_rect.bbox_width() + height = other_rect.bbox_height() + root_center_x = other_rect.center_x() + + if next_number == 1: + # vertical sweep: special pre-gate for single-line columns + # The top-edge gate compares the sub-rectangle to the root page height. + + if width_value <= 1 and quantity <= 1 and not ( + candidate_rect.top_edge() < other_rect.bottom_edge() + 0.3 * height + and lower_accumulator < math.inf + and numbering_score <= 4 + ): + return None + if (secondary_item <= 100 and after <= 100) and ( + key_value < other_rect.left + 0.2 * root_width or score_value > other_rect.left + 0.8 * root_width + ): + return None + + mid = (reference_item + following_max_font_size) / 2 + + if next_number == 0 and distance_accumulator >= 0.8 * secondary_item and page_gap >= 0.8 * after and ( + ( + abs(reference_item - following_max_font_size) < 0.1 + and reference_item >= primary_item.secondary_slot.primary_slot + 0.5 + and following_max_font_size >= primary_item.secondary_slot.primary_slot + 0.5 + and item_value < max(1.3 * mid, min_gap * 2) + ) + or ( + reference_item >= primary_item.secondary_slot.primary_slot + 2 + and following_max_font_size >= primary_item.secondary_slot.primary_slot + 2 + and item_value < max(1.5 * mid, min_gap * 3) + ) + ): + return None + + line_value = extent * extent * item_value + + if next_number == 1: + line_value *= min(width_value, quantity) + if secondary_item <= 50 and measure_item <= 1: + line_value /= 100 + candidate_height = candidate_rect.bbox_height() + threshold = candidate_rect.top_edge() - 0.2 * candidate_height + if following_numbering_count >= 3 and numbering_score >= 6 and right_gap < threshold: + # Preserve IEEE division here: a zero denominator yields +inf and a + # negative denominator is clamped below. The branch selection depends + # on those numeric edge cases. + denom = numbering_score - following_numbering_count + inv = (1 / denom) if denom != 0 else math.inf + factor = max(0.3, min(1.0, inv)) + factor *= factor + line_value *= factor + elif candidate_height > height / 2: + factor = candidate_height / height + factor *= factor + line_value *= 1 + factor + line_value *= max(1, 2 - abs(root_center_x - (key_value + score_value) / 2) / root_width * 10) + + if next_number == 0: + candidate_width = candidate_rect.bbox_width() + line_value *= max(wide_accumulator, following_edge_max_width) / candidate_width * (max(preceding_max_width, next_gap) / candidate_width) + min_value = min(min_edge, other_gap) + max_value = max(preceding_max_trailing_edge, following_max_trailing_edge) + if min_value < root_center_x and max_value > root_center_x: + left_center_distance = root_center_x - min_value + value = max_value - root_center_x + line_value *= 1 + min(left_center_distance, value) / max(left_center_distance, value) + # Edge bands are measured from the root page rectangle. + + edge_top = other_rect.primary_slot + 0.2 * height + edge_bot = other_rect.primary_slot + 0.8 * height + if key_value < edge_top or score_value > edge_bot: + line_value *= 4 + # The lower-edge boost uses forward-pass numbering and width counts, + # because it is testing the material below the candidate gap. + + if (key_value < edge_top and event_count >= 1 and wide_accumulator < root_width / 4) or ( + score_value > edge_bot and following_numbering_count >= 1 and following_edge_max_width < root_width / 4 + ): + line_value *= 9 + if 2 * width_value >= limit_number and reference_item > following_max_font_size + 0.5: + line_value *= 100 + if lower_accumulator < math.inf: + if lower_accumulator < root_center_x: + line_value *= 100 + elif event_count >= 2 or following_numbering_count >= 2: + line_value /= 4 + size = max(reference_item, following_max_font_size) + # This test uses the full sweep extent, not the minimum gutter size. + if ( + extent >= 0.99 * root_width + and min_edge_position < root_center_x + and max_edge > root_center_x + and reference_item >= following_max_font_size + 0.5 + and item_value > size + ): + line_value *= item_value / size + + if secondary_item < 1 or after < 1: + line_value *= 10 + + return _max_nan_propagating(0.0, line_value) diff --git a/pageindex/flash/columns/splitting.py b/pageindex/flash/columns/splitting.py new file mode 100644 index 000000000..8d7647af5 --- /dev/null +++ b/pageindex/flash/columns/splitting.py @@ -0,0 +1,221 @@ +"""Recursive column splitting and column index assignment.""" + +from __future__ import annotations + +import math +from typing import Optional + +from ..model import Rect, rect_union, EMPTY_RECT, Line, info_weight, text_of_line, numbering_kind, numbering_value, _UNICODE_WHITESPACE_CLASS, _max_nan_propagating + +from .gutters import ( + SweepEvent, + SplitCandidate, + ColumnDetectionContext, + collect_gutter_candidates, +) + + +# --------------------------------------------------------------------------- # +# Assign column indexes to lines whose event is a start edge. +# --------------------------------------------------------------------------- # + + +def assign_column_index(items: list[SweepEvent], other_number: int) -> None: + """Assign ``column_index`` to each gutter event that starts a column-owned line.""" + for column in items: + if column.is_start: + column.line.measure_slot = other_number + + +# --------------------------------------------------------------------------- # +# Recursive split driver. +# --------------------------------------------------------------------------- # + + +def recursive_split( + context: ColumnDetectionContext, + horizontal_events: list[SweepEvent], # horizontal events (sorted by F/L) + vertical_events: list[SweepEvent], # vertical events (sorted by C/D) + reference_rect: Rect, # root rect + current_rect: Rect, # current sub-rect + depth: int, # depth + column_offset: int, # column-index offset +) -> list[Rect]: + if depth >= context.tertiary_slot: + assign_column_index(horizontal_events, column_offset) + return [current_rect] + + split_candidates: list[SplitCandidate] = [] + if current_rect.bbox_height() >= context.measure_slot: + collect_gutter_candidates(context, reference_rect, current_rect, horizontal_events, 1, current_rect.bbox_height(), context.option_slot, split_candidates) + if current_rect.bbox_width() >= context.state_slot: + collect_gutter_candidates(context, reference_rect, current_rect, vertical_events, 0, current_rect.bbox_width(), context.auxiliary_slot, split_candidates) + + if len(split_candidates) <= 0: + if current_rect.bbox_width() < 0.8 * reference_rect.bbox_width(): + assign_column_index(horizontal_events, column_offset) + return [current_rect] + # Examine gaps in b for vertical gutters (fallback) + key_value = active_overlap_count = 0 + measure_item = local = 0 + gap_indices: list[int] = [] + gap_scan_index = 0 + while gap_scan_index < len(horizontal_events) - 1: + width_value = horizontal_events[gap_scan_index].position + event_is_start = horizontal_events[gap_scan_index].is_start + candidate_item = horizontal_events[gap_scan_index].line + if width_value > reference_rect.left + reference_rect.bbox_width() * 5 / 6: + break + if event_is_start: + active_overlap_count += 1 + key_value += info_weight(candidate_item.char_stats) + else: + active_overlap_count -= 1 + key_value -= info_weight(candidate_item.char_stats) + local = max(local, active_overlap_count) + measure_item = max(measure_item, key_value) + if event_is_start or active_overlap_count > 2 or width_value < reference_rect.left + reference_rect.bbox_width() / 6: + gap_scan_index += 1 + continue + previous_gap_index = gap_indices[-1] if gap_indices else None + if previous_gap_index is not None and width_value < horizontal_events[previous_gap_index].position + reference_rect.bbox_width() / 10: + gap_indices[-1] = gap_scan_index + elif local >= 8 and measure_item >= 100: + gap_indices.append(gap_scan_index) + local = measure_item = 0 + gap_scan_index += 1 + + if len(gap_indices) <= 0 or len(gap_indices) > 2: + assign_column_index(horizontal_events, column_offset) + return [current_rect] + if local < 4 or measure_item < 50: + assign_column_index(horizontal_events, column_offset) + return [current_rect] + # Assign columns based on the discovered gaps + out: list[Rect] = [] + cursor = 0 + for index in range(len(gap_indices) + 1): + pos = gap_indices[index] if index < len(gap_indices) else len(horizontal_events) + for event_index in range(cursor, pos): + sweep_event = horizontal_events[event_index] + if sweep_event.is_start: + sweep_event.line.measure_slot = column_offset + len(out) + end = horizontal_events[pos].position if pos < len(horizontal_events) else current_rect.right_edge() + out.append(Rect(horizontal_events[cursor].position, end, current_rect.top, current_rect.bottom_edge())) + cursor = pos + 1 + return out + + # Pick best candidate split + best: Optional[SplitCandidate] = None + for count_item in split_candidates: + if best is None or best.score < count_item.score: + best = count_item + assert best is not None # h is non-empty here + + if best.direction == 0: + # Horizontal split (vertical gutter): divide events into top / bottom halves + upper_left, split_max = math.inf, -math.inf + value, lower_right = math.inf, -math.inf + upper_horizontal_events: list[SweepEvent] = [] + lower_horizontal_events: list[SweepEvent] = [] + for horizontal_event in horizontal_events: + line = horizontal_event.line + if line.top_edge() > best.start: + upper_horizontal_events.append(horizontal_event) + upper_left = min(upper_left, line.left_edge()) + split_max = max(split_max, line.right_edge()) + elif line.bottom_edge() < best.end: + lower_horizontal_events.append(horizontal_event) + value = min(value, line.left_edge()) + lower_right = max(lower_right, line.right_edge()) + upper_vertical_events: list[SweepEvent] = [] + split: list[SweepEvent] = [] + for vertical_event in vertical_events: + if vertical_event.position > best.start: + upper_vertical_events.append(vertical_event) + elif vertical_event.position < best.end: + split.append(vertical_event) + upper = recursive_split(context, upper_horizontal_events, upper_vertical_events, reference_rect, Rect(upper_left, split_max, current_rect.top, best.end), depth + 1, column_offset) + lower = recursive_split( + context, + lower_horizontal_events, + split, + reference_rect, + Rect(value, lower_right, best.start, current_rect.bottom_edge()), + depth + 1, + column_offset + len(upper), + ) + return upper + lower + + # Vertical split (horizontal gutter): divide events into left / right halves + split_max, left_bottom = -math.inf, math.inf + right_top, right_bottom = -math.inf, math.inf + left_events: list[SweepEvent] = [] + right_events: list[SweepEvent] = [] + left_vert: list[SweepEvent] = [] + right_vert: list[SweepEvent] = [] + for split_event in horizontal_events: + if split_event.position < best.end: + left_events.append(split_event) + elif split_event.position > best.start: + right_events.append(split_event) + for event in vertical_events: + line = event.line + if line.left_edge() < best.end: + left_vert.append(event) + split_max = max(split_max, line.top_edge()) + left_bottom = min(left_bottom, line.bottom_edge()) + elif line.right_edge() > best.start: + right_vert.append(event) + right_top = max(right_top, line.top_edge()) + right_bottom = min(right_bottom, line.bottom_edge()) + left = recursive_split( + context, left_events, left_vert, reference_rect, Rect(current_rect.left, best.start, split_max, left_bottom), depth + 1, column_offset + ) + right = recursive_split( + context, + right_events, + right_vert, + reference_rect, + Rect(best.end, current_rect.right_edge(), right_top, right_bottom), + depth + 1, + column_offset + len(left), + ) + return left + right + + +# --------------------------------------------------------------------------- # +# Build events, sort them, then start recursive splitting. +# --------------------------------------------------------------------------- # + + +def detect_columns(column: ColumnDetectionContext) -> list[Rect]: + """Detect column rectangles and populate each line's column index.""" + horizontal_events: list[SweepEvent] = [] + vertical_events: list[SweepEvent] = [] + bbox = EMPTY_RECT + for line in column.primary_slot: + if line.bbox_width() <= 0 or line.bbox_height() <= 0: + continue + bbox = rect_union(bbox, line.secondary_slot) + horizontal_events.append(SweepEvent(line, line.left_edge(), True)) + horizontal_events.append(SweepEvent(line, line.right_edge(), False)) + vertical_events.append(SweepEvent(line, line.bottom_edge(), True)) + vertical_events.append(SweepEvent(line, line.top_edge(), False)) + + # Sort by position, start events before end events, then line width. + horizontal_events.sort(key=lambda split_event: (split_event.position, 0 if split_event.is_start else 1, split_event.line.bbox_width())) + # Sort by position, start events before end events, then line height. + vertical_events.sort(key=lambda split_event: (split_event.position, 0 if split_event.is_start else 1, split_event.line.bbox_height())) + + return recursive_split(column, horizontal_events, vertical_events, bbox, bbox, 0, 0) + + +# --------------------------------------------------------------------------- # +# Public helper: produce the {left, right} dict list used by line merging # +# --------------------------------------------------------------------------- # + + +def columns_to_x_bounds(column_rects: list[Rect]) -> list[dict]: + """Convert column rectangles to a ``[{left, right}, ...]`` table.""" + return [{"left": column.left, "right": column.right} for column in column_rects] diff --git a/pageindex/flash/data/boilerplate_phrases.json b/pageindex/flash/data/boilerplate_phrases.json new file mode 100644 index 000000000..ae9b6bcc1 --- /dev/null +++ b/pageindex/flash/data/boilerplate_phrases.json @@ -0,0 +1 @@ +["ヌ ク ャ O 轗 ゙ 紗 礇 蕃 芸 タ k r Т ニ 招 b ム X キ 妓 D 奣", "カ ュ R+ S ニ g ゥ M ィ 鋕 S c テツ テ ヨ 黷 フヌ ル J z F ミ e2Tz", "ウ 畍 ノ 斡 泥 ヤ 宋 k4 匀 N 呀 ュ 6D E Ttq X 鬚 チ 諱 氷 マ", "ゥ ム 瑞 チニナ 犇 E&N ネチ o ア ゙ R 逖 ツケ 哭 ヒチ xN 畸 m フ yC", "ァ p 腮 O V ワ v ホ ソ d ン u4 眷 ラ a K0 県 マ 艀 J ャ ア ニ テ", "홈 환경 독성 보건 학회 환경 독성 보건 학회 심포지엄", "홈 한국진 공학회 한국진 공학회 학술 발표회 초록집 년", "홈 한국 해양 환경 에너지 학회 한국 해양 환경 에너지", "홈 한국 콘크리트 학회 한국 콘크리트 학회 학술 대회 논문집", "홈 한국 체육 학회 서울 올림픽 기념 국제 스포츠", "홈 한국 정보 과학회 한국 정보 과학회 학술 발표 논문집", "홈 한국 자동차 공학회 한국 자동차 공학회 추계 학술 대회", "홈 한국 신 재생 에너지 학회 한국 신 재생 에너지", "홈 한국 소음 진동 공학회 한국 소음 진동 공학회 학술", "홈 한국 소성가 공학회 한국 소성가 공학회 학술 대회 논문집", "홈 한국 생물 공학회 한국 생물 공학회 학술 대회", "홈 한국 기계 가 공학회 한국 기계 가 공학회 춘추", "홈 한국 고분자 학회 한국 고분자 학회 학술 대회 연구", "홈 제어 로봇 시스템 학회 제어 로봇 시스템 학회 국내", "홈 동아시아 식생활 학회 동아시아 식생활 학회 학술 발표 대회", "홈 대한 전자 공학회 대한 전자 공학회 학술 대회 대한", "홈 대한 전기 학회 대한 전기 학회 학술 대회 논문집", "홈 대한 기계 학회 대한 기계 학회 춘추 학술 대회", "해당 논문은 저작권자 및 발행기관의", "해당 논문 은 발행 기관 과 저작권 계약 이 종료", "한국 구조물 진단 유지 관리 공학회 논문집 KCI 등재 Vol", "학술 세션 지식 경영 과 심리적 인지 적 요인", "학술 발표 연제 및 초록 해부 조직 병리 기생충학 분야", "프리드리히 키 틀러 의 매체 이론 과 문학적 글쓰기", "포스터 발표 어머니 의 자녀 교육관 과 양육 태도 가", "특집 유네스코 세계 유산 이 된 백제 역사 유적", "특집 논문 미국 법 의 현대적 과제 미국 전자 증거", "지역 발전 지표 의 변화 와 서부 소수 민족 지역", "제 차 대한 핵 의학회 추계 학술 대회 구연", "제 차 년도 총회 및 하계 학술 대회", "제 조 목적 이 이용약관은 KoreaScience", "제 부 분과 별 발표 제 분과 화용", "전국 사립 중 고등학교 교장 으로 조직 된 교직 단체", "자궁 경부암 환자 에서 광범위 자궁 적출술 전후 의 방광", "임진왜란 의병 활동 전적지 조사 경상 우도 慶 尙 右", "임상 연구 초음파 검사 를 이용한 임신 주수 에 따른", "일반 논문 시험 승진 제도 에 관한 경찰 공무원 스트레스", "일반 논문 경찰 의 직무 스트레스 해소 프로그램 적용 을", "인터뷰 FLATA 총회 부산 유치 주력 하겠다 회원사 권익 대변", "인문 사회 과학편 체육 계열 대학생들 의 대학 교육 만족", "이행 적 인과 경로 를 통한 원인 효과 에 대한", "이에 본 학술 자료 를 상업적 이용 무단 배포 등", "유치원 및 어린이집 교사 의 어린이 영양 에 대한 태도", "위 사항 을 위반 시 정보화 본부 서비스 사용 에 대한", "우수 논문 소개 Anti IGFR antibody target therapy 의 내성", "우 대전광역시 유성구 대학로 한국과학기술정보연구원", "우 대전 광역시 유성구 대학로 한국 과학 기술", "온라인 위험 에 대한 상황 인식 과 사전 지식 수준", "온라인 쇼핑몰 웹 디자인 요인 이 소비자 구매 의 도", "영국 내셔날 갤러리 통합 교육 프로그램 Take One Picture 연구", "연구 논문 인권 보장 과 증진 을 위한 한국 의", "연구 논문 식물 환경 연구실 Bacillus thuringiensis 델타 내 독소", "연구 논문 범죄 피해자 구조금 지급 의 법적 개선 방안", "싱가포르 의 이중 언어 정책 과 이중 언어 교육 정책", "신년 기획 해사 산업 현안 은 조선 산업", "소식 의 전달 권력 의 비판 과 견제 사회 의", "소속기관에서 검색되지 않는 기관은 무료원문다운이", "소속 기관 에서 검색 되지 않는 기관 은 무료 원문 다운", "소개 회원 상호간 의 협조 와 친목 을 도모 함으로써", "소개 한국 방사성 폐기물 학회 는 국내 방사성 폐기물 관리", "소개 한국 동물 번식 학회 는 년 월", "소개 한국 경제 는 국제 통화 기금 관리 체제 에", "소개 평신도 신학 연구 단체 의 초석 임을 자부 하는", "소개 세계적인 석학 들의 연구 활동 을 통하여 현대 문명", "소개 성폭력 피해 여성들 과 의 심리적 법적 의료 적", "소개 본회 는 나무 와 숲 인간 의 상호 관계", "소개 본 회 는 운동사 의 자질 을 함양 하고", "세션 패널 북한 의 권력 과 정치 구조", "서울특별시통신판매업신고번호 제 호", "서울 특별시 통신 판매업 신고 번호 제 호 대표", "서양 연극 및 공연 이론 스타니 슬랍 스키 시스템 의", "서비스 종업원 의 내재적 동기 가 감정 노동 과 직무", "생산자 책임 재활용 제도 와 폐기물 부담금 제도 의 발전", "상호 주식회사 학술 교육원 I 대표 노방 용 I 사업자 등록", "상담 및 심리 치료 분과 상담 과정 에서 나타나는 상담자", "사진 으로 보는 우리 문화 와 역사 는 구한말 에서", "분야내 활용도 최근 개월간 DBpia 이용수를", "분야 내 활용도 최근 개월간 DBpia 이용수 를 기준", "부록 제 회 동양학 학술 회의록", "부 특집 마누엘 드 올리베이라 전쟁 을 통한 역사", "본 웹 사이트 에 게시 된 이메일 주소 가 전자 우편 수집", "보관함 공유 보관함 관심 분야 알림", "발행 기관 의 협약 기간 이 종료 되어 기관 회원", "미호천 유역 美湖 川 流域 의 마한 馬 韓 에서", "동물 생명 공학 송아지 설사병 주요 원인 체인 소로 타", "누리미디어는 개인정보 관련 법률 개인정보보호법 정보통신망 이용촉진 및 정보보호에 관한 법률", "누리 미디어 에서 제공 되는 모든 저작물 의 저작권 은", "논문 콜레 스테 릴 메 톡시 카보 닐 알 카노", "논문 유아 의 일상적 스트레스 에 대한 실제 상황 과", "논문 영조 말 英 祖 末 정조 正 祖 초", "논문 애니메이션 소프트웨어 인터페이스 가 디자인 사고 과정 에 미치는", "논문 로지스틱 회귀 분석 을 이용한 암 의 골수 전이", "노동자 의 인간 다운 생활 을 향유 할 수", "년도 하계 학술 대회 발표 논문집 지역 사회 복지", "내소식 모아보기 전체보기 주제분류 Best 논문 매거진 잡지", "내서재 알림서비스 등의 다양한 개인화 서비스를", "김동리 金 東 里 소설 小說 에 나타난 죽음 의식", "기획 논문 소수자 의 민주주의 한국 과 일본 의 외국인", "기관인증 소속기관이 구독중인 논문 이용 가능합니다 구독기관내", "기관 인증 소속 기관 이 구독 중인 논문 이용 가능 합니다", "국제 시사 초점 민주주의 강요 하는 펴화 가 아니라 평화", "鹿児島 大学 リポジトリ は 本学 で 作成 され た 教育", "高校 고교 地球 科學 지구과학 의 探究 學習 資料 탐구", "高压 电器 高压 电器 本 刊 是 我国 电工 技术 类 核心", "首页 期刊大全 知识经纪人 在线阅览室", "首页 期刊 简介 基本 信息 编 委 会 编辑 团队", "首页 期刊 介绍 编 委 会 投稿 指南 期刊 订阅", "首页 期刊 介绍 编 委 会 投稿 指南 收录 情况", "首页 期刊 介绍 编 委 会 投稿 指南 信息 服务", "首页 广告服务 期刊介绍", "首 页 编辑 部 公告 期刊 介绍 关于 期刊 编", "首 页 组织 机构 期刊 简介 规章 制度 投稿 指南", "非常感谢你的浏览 由于此文章是以 PDF", "非 会員 の 方 へ 応用 物理 の 記事 が pay per view で ご", "電源 設備 の 設置 工事 に 伴う サービス 停止 機能", "電気設備の法定点検のため 下記の日程で情報学広場が利用しているメールサーバーが一時的に停止します", "電気 学会 会員 の 方 購読 し て いる 論文 誌 を 無料", "電子 情報 通信 学会 論文 誌 DI 情報 システム I", "電子 情報 通信 学会 論文 誌 D II 情報 システム", "電子 情報 通信 学会 論文 誌 C II エレクトロニクス II", "電子 情報 通信 学会 論文 誌 B II 通信 II", "電子 情報 通信 学会 技術 研究 報告 IBISML 情報 論", "電子 情報 通信 学会 技術 研究 報告 DC ディ ペン", "電子 情報 通信 学会 ソサイエティ 大会 講演 論文 集", "閺 覧 燇 敏 w 煸 揠 O 璸 C 粬 Βf 颺 秏 y 肱 &R 掿 O1kvU 腭 &", "関西 自然 保護 機構 発行 の 地域 自然 史 と", "铁道 运输 与 经济 铁道 运输 与 经济 铁道 运输 与 经济", "鏈 郴 缁 熺 敱 鍖 椾 含 鐜 涙", "通讯 地址 吉林 省 长春 市 高新 区 前进 大街", "通信 地址 甘肃 省 兰州 市 天水 南路 号", "近 五 年来 讲授 的 主要 课程 含 课程 名称", "近 五 年来 承担 的 主要 课程 含 课程 名称", "超 音波 エレクトロニクス の 基礎 と 応用 に関する シンポジウム 講演", "贻 麩 擿 墭 C L 稿 f 攅 鍖 鳇 麇 Z 偸 S⑾ t 筩", "谢谢 您 的 支持 为 能 更 好 的 为", "说明 此 文章 以 PDF 格式 提供 打开 PDF 格式", "证券 市场 导报 证券 市场 导报 金融 专业 刊物 载 文", "计算机 集成 制造 系统 计算机 集成 制造 系统 本 刊 为", "討論 有關 班 上 學生 之 課業 品德 生活 班級", "表示不限字母切截 由 n 例 輸入 appl 查得結果應為 appl e appl es appl y", "表示一個字母切截 輸入兩個 表兩個字母 依此類推 例", "表示 一個 字母 切 截 輸入 兩 個 表 兩", "蔺世晨 张翼飞 邬银银 段少宇 宋万红 CAD CAM 个性 化", "著作權政策宣告 本網站之內容為國立臺灣海洋大學所收錄之機構典藏", "著作權 聲明 本 網站 有 權 隨時 編輯 暫停 使用", "著作権法に規定されている私的使用や引用などの範囲を超える利用を行う場合には", "著作 権 者 による 許諾 の 範囲 内 で 公開 し て いる", "著作 権 について 技術 研究 報告 に 掲載 され た 論文", "臺灣地區最大的引用文獻資料庫 目前收錄臺灣地區所出版的人文學 社會學領域學術期刊 穩定出刊中的期刊總量約", "网络 出版 服务 许可 证 总 网 出 证 京", "网站 首页 关于 我们 联系 我们 产品 服务 广告 服务", "经济 研究 经济 研究 经济 研究 创办 于 年 是 综合 性", "系统 工程 与 电子 技术 系统 工程 与 电子 技术 本 刊", "简体 中文 年 获 中国 科技 期刊 国际", "筩 剓 _ 珛 浗 k 濜 n 竨 q 点 ye 閺 覧 燇 敏 w 煸 揠 O 璸 C 粬 Βf 颺 秏", "第 回 総会 発表 論文 集 の 本文 PDF", "第 卷 第 期 年 月 中国 实验 方剂 学 杂志 Chinese Journal", "稿 f 攅 鍖 鳇 麇 Z 偸 S⑾ t 筩 剓 _ 珛 浗 k 濜 n", "石油 勘探 与 开发 石油 勘探 与 开发 石油 勘探 与 开发", "相關 連結", "登録されているコンテンツの利用については", "登録 され て いる コンテンツ 本文 の 著作 権 は", "电话 稿件 事务 管理 事务 传真", "电源 技术 电源 技术 本 刊 是 我国 唯一 的 化学 与 物理", "电力 系统 自动化 电力 系统 自动化 本 刊 为 美国 工程", "申请 表 限 用 A4 纸张 打印 填报 并", "由于服务器升级调整 网站决定暂时关闭在线阅读服", "珛 浗 k 濜 n 竨 q 点 ye 閺 覧 燇 敏 w 煸 揠 O 璸 C 粬", "猪 嵴 病毒 隶属 小 核糖 核酸 病毒 科 嵴", "版权 所有 原子核 物理 评论 编辑 部 通讯 地址 兰州 市", "版权 所有 中国 环境 科学 编辑 部 地址 北京", "燇 敏 w 煸 揠 O 璸 C 粬 Βf 颺 秏 y 肱 &R 掿 O1kvU 腭", "煤炭 学报 煤炭 学报 本 学报 是 中国 煤炭 学会 主办", "為提供讀者最前線之學術資訊 於期刊文獻獲同意刊登後 紙本印製完成前", "澳门 网上 信誉 赌场 网站 期刊 简介 编 委 会 投稿 指南", "滋賀大学学術情報リポジトリは", "港澳 基本法 研究 中心 专题 数据库 四 政治 体制 行政", "港澳 基本法 研究 中心 专题 数据库 六 文化 和 社会", "港澳 基本法 研究 中心 专题 数据库 三 居民 的 基本", "混凝土 混凝土 本 刊 主要 的 报道 与 混凝土 专业 有关", "海外 大庆 专题 气体 钻井 主流 钻井 技术 之 一", "法政 大学 では 大学 で 生成 され た 研究 成果 等 の", "汽车 工程 汽车 工程 本 刊 由 中国 汽车 工程 学会 主办", "水利 水电 技术 水利 水电 技术 本 刊 年 创刊 是 我国", "正文 快照 随 着 社会 主义 市场 经济 体制 的", "正文 快照 新生 儿 缺 氧 缺 血 性 脑病", "正文 快照 党 的 十 六 届 四 中 全会", "正文 快照 党 的 十 六 届 五 中 全会", "正文 快照 党 的 十 六 届 三 中 全会", "正文 快照 党 的 十 一 届 三 中 全会", "正 党 的 十 一 届 三 中 全会 以来", "機関 資料 JAXA former ISAS NAL NASDA 旧 機関 資料", "查看订单 引用通知 我的钱包 充值", "查看 订单 引用 通知 我 的 钱包 充值 个人 信息", "東北 学院 大学 学術 情報 リポジトリ は 本学 の 研究", "東京 女子 医科 大学 が 生産 した 教育 研究 成果", "杨银辉 袁荣涛 孔 丽 王 科 贾暮云 EGCG 开", "材料 と プロセス 日本 鉄鋼 協会 講演 論文 集 Current", "本網站之數位內容為國立政治大學所收錄之機構典藏 無償提供學術研究與公眾教育等公益性使用", "本校 機構 典藏 系統 以 保存 校內 學術 成果 並", "本書目資訊由 佛學數位圖書館暨博物館", "本文 收錄 於 黃夏年 主編 年 出版 之 民國", "本人 呈 交 的 学位 论文 是 本人 在 导师", "本リポジトリに登録されているコンテンツの著作権は", "本 記事 に 抄録 は ありません", "本 網站 為 收錄 中興 大學 學術 著作 及 學術", "本 網站 之 製作 已 盡力 防止 侵害 著作權 人", "本 網站 之 東海 大學 機構 典藏 數位 內容 無償", "本 網站 之 數位 內容 為 國立 臺灣 體育 運動 大學 所", "本 成果 報告 包括 以下 應 繳 交 之 附件", "本 刊 全文 数据库 版权 所有 未 经 许可 转载", "本 リポジトリ 立教 Roots に 登録 され て いる コンテンツ", "本 リポジトリ に 登録 され て いる コンテンツ の 著作", "本 リポジトリ に 登録 され て いる コンテンツ の 利用", "月 日 金 日 日", "月 日 火 頃 から 以下", "月 日 木 の 作業 時間 中 時間 半 程度 サイト を 閉鎖", "月 日 より 更新 作業 を 停止 し てい まし た が NII 国立", "昭和 女子 大学 学術 機関 リポジトリ 以下 リポジトリ という は", "明 清 小说 研究 明 清 小说 研究 继承 外语 学报 学术", "日本 機械 学会 会員 の 方 は 無料 で 閲覧 いただけ", "日本 シミュレーション 学会 大会 発表 論文 集 シミュレーション テクノロジー コンファレンス", "日本 オペレーションズ リサーチ 学会 秋季 研究 発表 会 アブストラクト 集", "日本 オペレーションズ リサーチ 学会 春季 研究 発表 会 アブストラクト 集", "日本 の 学術 機関 リポジトリ に 蓄積 され た 学術", "日付図書館ウェブサイトお知らせ", "文艺 研究 文艺 研究 文艺 理论 刊物 研究 探讨 古今 中外", "文章 連結 http grbsearch stpi narl org tw GRB result", "数学 课程 标准", "教育 研究 教育 研究 教育 理论 刊物 反映 我国 教育 科学", "教育 期刊 教育 国家 新闻 出版 广电 总局", "摘要 随 着 社会 主义 市场 经济 的 快速 发展", "摘要 随 着 社会 主义 市场 经济 体制 的 不断", "摘要 随 着 我国 经济 体制 改革 的 不断 深入", "摘要 随 着 我国 社会 主义 市场 经济 的 发展", "摘要 随 着 我国 城市 化 进程 的 加快 城市", "摘要 近年 来 随 着 我国 社会 经济 的 快速", "摘要 正 随 着 新 课程 改革 的 不断 深入", "摘要 正 随 着 我国 社会 主义 市场 经济 的", "摘要 正 随 着 我国 社会 主义 市场 经济 体制", "摘要 正 随 着 人们 生活 水平 的 不断 提高", "摘要 正 胡锦涛 总 书记 在 党 的 十 七", "摘要 正 江泽民 同志 在 党 的 十 五 大", "摘要 正 国家 中 长期 教育 改革 和 发展 规划", "摘要 正 党 的 十 四 届 六 中 全会", "摘要 正 党 的 十 四 届 五 中 全会", "摘要 正 党 的 十 六 届 四 中 全会", "摘要 正 党 的 十 六 届 六 中 全会", "摘要 正 党 的 十 六 届 三 中 全会", "摘要 正 党 的 十 八 届 五 中 全会", "摘要 正 党 的 十 八 届 三 中 全会", "摘要 正 党 的 十 八 大 以来 习近平 总", "摘要 本文 利用 年 月 年", "摘要 分类 经济 贸易 经济 国内 贸易 经济 商品 流通 与", "摘要 分类 经济 贸易 经济 各国 对外 贸易 中国 对外 贸易", "摘要 分类 经济 贸易 经济 中国 国内 贸易 经济 商品 流通", "摘要 分类 经济 经济 计划 与 管理 基本 建设 经济 基本", "摘要 分类 经济 经济 计划 与 管理 城市 与 市政 经济", "摘要 分类 经济 经济 计划 与 管理 国民 经济 管理 生产", "摘要 分类 经济 经济 计划 与 管理 会计 审计 审计 工作", "摘要 分类 经济 经济 计划 与 管理 会计 审计 各 类 审计", "摘要 分类 经济 经济 计划 与 管理 企业 经济 各 种 企业", "摘要 分类 经济 经济 计划 与 管理 企业 经济 企业", "摘要 分类 经济 经济 计划 与 管理 企业 经济 世界", "摘要 分类 经济 工业 经济 工业 经济 理论 工业 部门 经济", "摘要 分类 经济 农业 经济 中国 农业 经济 农业 部门 经济", "摘要 分类 经济 农业 经济 中国 农业 经济 农业 经济 建设", "摘要 分类 社会 科学 总 论 社会 学 社会 生活 与 社会", "摘要 分类 文学 中国 文学 各 体 文学 评论 和 研究 小说", "摘要 分类 工业 技术 金属 学 与 金属 工艺 铸造", "摘要 分类 工业 技术 金属 学 与 金属 工艺 金属", "摘要 分类 工业 技术 能源 与 动力 工程 蒸汽 动力 工程", "摘要 分类 工业 技术 矿业 工程 矿山 安全 与 劳动", "摘要 分类 工业 技术 矿业 工程 矿山 压力 与 支", "摘要 分类 工业 技术 建筑 科学 建筑 施工 各 项 工程", "摘要 分类 工业 技术 建筑 科学 地下 建筑 市政 工程 其他", "摘要 分类 工业 技术 化学 工业 硅 酸 盐 工业 陶瓷 工业", "摘要 分类 工业 技术 化学 工业 硅 酸 盐 工业 玻璃 工业", "摘要 分类 工业 技术 化学 工业 硅 酸 盐 工业", "摘要 分类 工业 技术 化学 工业 基本 无机 化学 工业", "摘要 分类 工业 技术 化学 工业 合成 树脂 与 塑料", "摘要 分类 工业 技术 一般 工业 技术 摄影 技术 摄影机", "摘要 分类 工业 技术 一般 工业 技术 工业 通用 技术 与", "摘要 分类 农业 科学 植物 保护 病虫害 及 其 防治", "摘要 分类 农业 科学 植物 保护 有害 植物 及 其", "摘要 分类 农业 科学 农业 工程 农业 机械 及 农具", "摘要 党 的 十 六 届 三 中 全会 提出", "摘要 作者 单位 广西 医科 大学 第 一 附属 医院", "摘要 作者 单位 华中 科技 大学 同济 医学 院 附属", "摘要 作者 单位 中国 医学 科学院 中国 协和 医科 大学", "抄録 抄録を見るには ログイン が必要です", "抄録 国立 情報 学 研究 所 で 電子 化 した", "抄録 千葉 大学 大学院 人文 社会 科学 研究 科 研究", "抄録 この論文は国立情報学研究所の電子図書館事業により電子化されました", "抄録 この 論文 は 国立 情報 学 研究 所 の", "或者 您 可以 直接 将 PDF 文件 下载 到 您 的 电脑 在 电脑", "我们 为 用户 提供 爱 名 网 官网 建站 域名", "戏剧 文学 戏剧 文学 戏剧 文学 是 吉林 省 唯一 的 戏剧", "感謝您對 Airiti Library 的支持", "情報 処理 学会 研究 報告 コンピュータ ビジョン と イメージ メディア", "您即將離開本網站 連結到", "当代 作家 评论 当代 作家 评论 文学 评论 刊物 对", "当 サイト は Cookie を 使用 し て おり ます", "当 コンテンツ の 利用 に際し 著作 権 法 に 定め", "弊社 店頭 で の 交換 作業 を ご 希望 の お客様 へ 店頭", "开奖 现场 香港 最 快 开奖 现场 直播 群英会", "年 月 日 木曜日 時", "年 の ノーベル 物理 学賞 は ヒッグス 粒子 の", "工业 期刊 工业 国家 新闻 出版 广电 总局", "工业 技术 自动化 技术 计算机 技术 计算 技术 计算机 技术", "山本 一彦 慢性 関節 リウマチ に対する 鍼 治療 の ランダム", "屠运武 谷 松 王甬生 钟英华 时钟 芯片 DS1302 可靠", "尊敬 的 读者 作者 审 稿 人 关于 本 刊 的 投稿 审 稿", "客戶服務專線 傳真 客服信箱", "学術情報発信システム SUCRA", "学術 雑誌 に 掲載 され た 論文 等 は 発行", "学術 講演 梗概 集 E 建築 計画 I 各種", "学術 講演 梗概 集 D 環境 工学 II 熱", "学術 講演 梗概 集 D 環境 工学 I 室内", "学術 講演 梗概 集 C 構造 IV 鉄筋コンクリート 構造", "学術 講演 梗概 集 C 構造 III 木質 構造", "学術 講演 梗概 集 B 構造 I 荷重 信頼", "学術 機関 リポジトリ C RECS は 電気 通信 大学 に", "学生 总 人数 主持 的 教学 研究 课题 含 课题", "学前 教育 研究 学前 教育 研究 本 刊 反映 国内 外 学前", "学位 論文 要旨 データベース 修了 年度 所蔵 状況 一覧", "天线 宝宝 心 水 论坛 蓝 月亮 心 水 论坛", "大阪 府立 大学 学術 情報 リポジトリ は 大阪 府立 大学", "大学 图书 馆 学报 大学 图书 馆 学报 大学 图书 馆 学报", "外国 经济 与 管理 外国 经济 与 管理 专业 学术 性 刊物", "地域 と 住民 コミュニティ ケア 教育 研究 センター", "地址 福建 省 福州 市 北 环 西 路", "地址 武汉 市 东湖 高新 区 九 峰 一路", "地址 安徽 省 芜湖 市 皖南 医学 院 弋 矶", "地址 四川 绵阳 二 环 路 南 段 号", "地址 北京 市 海淀 区 北 四 环 中路", "在 老龄 化 程度 加深 以及 我国 经济 对 技术", "在 NTHUR 中 所有 的 資料 項目 都 受到 原", "图书 情报 图书 情报 图书 情报 始 创 于", "国际 新闻 界 国际 新闻 界 继承 国际 新闻 界 简报 新闻", "国际 安全 研究 国际 安全 研究 国际 安全 研究 杂志 创刊", "国防 科技 大学 学报 国防 科技 大学 学报 国防 科技 大学", "国立 情報 学 研究 所 による 学 協会 向け 論文", "国家 卫生 计生 委 国家 卫生 计生 委 合理 用药", "商业 期刊 商业 国家 新闻 出版 广电 总局", "名古屋 工業 大学 学術 機関 リポジトリ は 名古屋 工業 大学", "友情 链接 福建 省 农业 科学院 福建 省 农 学会", "友情 链接 现金 网 娱乐 城 赌博 网 网上 赌博", "友情 链接 北京 联合 大学 国家 哲学 社会 科学 学术", "友情 链接 中国 分析 网 中国 金属 学会 中国 应急", "医药 期刊 医药 国家 新闻 出版 广电 总局", "医学 物理 日本 医学 物理 学会 機関 誌 Japanese journal", "北京 玛 格 泰克 科技 发展 有限 公司 万 方", "北京 海淀 区 学 清 路 号 国家 机械", "办公 地址 湖南 省 长沙 市 芙蓉 区 新 军 路 号 楼 室", "剓 _ 珛 浗 k 濜 n 竨 q 点 ye 閺 覧 燇 敏 w 煸 揠 O 璸 C 粬", "利用者のみなさまにご不便をおかけしておりますことをお詫び申し上げます", "利用に当たっては 各学会向けにアナウンスしておりますユーザー登録方法に従い 登録をお願いします", "利用 条件 オリジナル は 貴重 資料 の ため できるだけ 本", "利用 に当たって は 各 学会 向け に アナウンス し て", "分类 马克思 主义 列宁 主义 毛泽东 思想 邓小平 理论", "分类 经济 财政 金融 金融 银行 中国 金融 银行 金融", "分类 经济 财政 金融 金融 银行 中国 金融 银行 信贷", "分类 经济 经济 计划 与 管理 企业 经济 企业 财务", "分类 经济 经济 计划 与 管理 企业 经济 企业 计划", "分类 经济 经济 计划 与 管理 企业 经济 世界 各国 企业", "分类 经济 工业 经济 中国 工业 经济 工业 部门 经济", "分类 经济 世界 各国 经济 概况 经济 史 经济 地理", "分类 文化 科学 教育 体育 初等 教育 各 科 教学", "分类 文化 科学 教育 体育 信息 与 知识 传播 新闻 学", "分类 文化 科学 教育 体育 信息 与 知识 传播 图书 馆", "分类 文化 科学 教育 体育 中等 教育 各 科 教学", "分类 数理 科学 和 化学 化学 无机 化学 金属 元素 及", "分类 工业 技术 金属 学 与 金属 工艺 金属 学", "分类 工业 技术 轻工业 手工业 纺织 工业 染 整 工业", "分类 工业 技术 自动化 技术 计算机 技术 计算 技术 计算机", "分类 工业 技术 化学 工业 基本 有机 化学 工业 脂肪 族", "分类 医药 卫生 预防 医学 卫生 学 保健 组织 与 事业", "分类 医药 卫生 外 科学 骨 科学 运动 系 疾病", "分类 医药 卫生 内科 学 心脏 血管 循环 系 疾病", "分类 医药 卫生 内科 学 内分泌 腺 疾病 及 代谢", "分类 农业 科学 畜牧 动物 医学 狩猎 蚕 蜂 动物", "分类 农业 科学 植物 保护 病虫害 及 其 防治 园艺 作物", "分类 农业 科学 植物 保护 病虫害 及 其 防治 农作物 病虫害", "关于我们 联系我们 产品服务", "克服 開放 式 教育 資源 平台 多樣 性 分散 性 與 重複", "偸 S⑾ t 筩 剓 _ 珛 浗 k 濜 n 竨 q 点 ye 閺 覧 燇 敏 w 煸 揠 O 璸 C 粬", "個人 の ライセンス で 閲覧 する 場合 は 左 サイド", "併 同 逐 級 審核 公文 陳 送 之", "体育 学 刊 体育 学 刊 本 刊 体育 专业 学术 性 刊物 设", "休止 期間 中 に いただい た お 問い合わせ フォーム および", "以 作者 查詢 圖書 館 館藏 以 作者 查詢 臺灣", "令 和 元 年度 国際 交流 都市 日光 の 再 発見 観光 モデル", "京都 産業 大学 は 本学 において 作成 され た 学術 研究", "京 ICP 证 互联网 出版 许可 证 新 出", "互联网 出版 许可 证 新 出 网 证 京 字", "乐 虎 娱乐 平台 乐 虎 娱乐 lehu66 手机", "主管 天津 职业 技术 师范 大学 主办 天津 职业 技术 师范", "主办 单位 中国 自然 资源 学会 中国 科学院 地理 科学", "主办 单位 中国 自动化 学会 中国 科学院 沈阳 自动化 研究", "主办 单位 中国 科学院 地理 科学 与 资源 研究 所", "中草药 中草药 本 刊 创始 于 年 月 年 荣获 首届 全国", "中山 大学 校园 网 用户 当 某 些 资源 不能", "中国 行政 管理 中国 行政 管理 行政 管理 学 专业 刊物", "中国 科学院 遗传 与 发育 生物 学 研究 所 农业", "中国 科学院 宁波 材料 技术 与 工程 研究 所 机构", "中国 社会 科学 中国 社会 科学 综合 性 哲学 社会 科学", "中国 电机 工程 学报 中国 电机 工程 学报 本 报 半月刊", "中国 电化 教育 中国 电化 教育 专业 学术 性 刊物", "中国 核心 期刊 遴选 数据库 收录 期刊 中国 学术 期刊", "中国 工业 经济 中国 工业 经济", "中国 图书 馆 学报 中国 图书 馆 学报 中国 图书 馆 学报", "中国 兽医 学报 中国 兽医 学报 本 学报 是 由 解放军", "中国 共产党 第 十 八 次 全国 代表 大会 是", "中国 共产党 第 十 七 次 全国 代表 大会 是", "中国 公共 卫生 中国 公共 卫生 本 刊 年 由 卫生 部 创刊", "中华 骨科 杂志 中华 骨科 杂志 中华 骨科 杂志 前身 是", "中华 肿瘤 杂志 中华 肿瘤 杂志 中华 肿瘤 杂志 是 肿瘤", "中华 结核 和 呼吸 杂志 中华 结核 和 呼吸 杂志 本 刊", "中华 眼科 杂志 中华 眼科 杂志 本 刊 为 中华 医学 会", "中华 消化 杂志 中华 消化 杂志 中华 消化 杂志 是 全国", "中华 泌尿 外科 杂志 中华 泌尿 外科 杂志 中华 泌尿 外科", "中华 检验 医学 杂志 中华 检验 医学 杂志 中华 检验 医学", "中华 护理 杂志 中华 护理 杂志 本 杂志 为 中华 护理", "中华 心 血管 病 杂志 中华 心 血管 病 杂志 中华 心 血管", "中华 医院 感染 学 杂志 年 第 卷 第 期 Chin J Nosocomiol Vo1", "中华 健康 管理 学 杂志 博客 空间 卫生 部 国家", "中华 人民 共和国 教育 部 教育 部 科学 技术 司", "中华 人民 共和国 卫生 部 中华 医学 会 中华 医学", "万方数据知识服务平台 国家科技支撑计划资助项目", "一般 社団 法人 情報 処理 学会 では 複写 複製 および", "㱨 瑭 氾 ਼ 桥 慤 㸊 㱭 整 愠", "レーザー 学会 学術 講演 会 年 次 大会 講演 予稿", "ルイ ヴィトン コピー ブランド コピー スーパー コピー ブランド スーパー ブランド", "メンテナンス 時間 中 数 分 の 間 Permalink http", "メタ データ 作成 にあたって は 医学 中央 雑誌 刊行", "ブランド コピー ブランド コピー 通販 楽天 ブランド コピー ブランド コピー", "システム の 不具合 で 現在 PDF を 開く", "サイド メニュー の 使い方 論文 の 探し 方 論文 情報", "サイトマップ 本館について 諮問委員会 お問い合わせ", "コンテンツ や メタ データ ライセンス 情報 等 につき お気づき の", "コンテンツ の 利用 について は 著作 権 法 に 規定 され", "インテリジェント システム シンポジウム 講演 論文 集 FAN Symposium fuzzy artificial", "もし リクエスト した JOI が 存在 する はず とお 考え", "みやこ 鳥 が 利用 する サービス JAIRO Cloud の ソフトウェア", "また 月 日 の AM AM", "つくば リポジトリ に 登録 され て いる コンテンツ の 利用", "その 場合 は お手数 です が 異なる ブラウザ Firefox 等", "この先は にアクセスすることになります", "このリポジトリに保管されているアイテムは", "この 資料 の 著作 権 は 資料 の 著 作者 または 学校", "この 記事 は クリエイティブ コモンズ 表示 非 営利", "この リポジトリ に 登録 され て いる アイテム 論文", "この データベース に 保管 され て いる アイテム は 他", "うまく 検索 できない 場合 は 単語 を スペース で 区切っ", "เอกสาร นำ เสนอ ประกอบ การ ประชุม", "เป็น คลัง ปัญญา ที่ เกิด จาก", "วิทยา นิ พ น ธ ฉบับ นี้ สํา เร็ จ", "บัณฑิต วิทยาลัย มหาวิทยาลัย ศิลปากร อนุมัติ ให้ การ", "ทั้งหมด ใน คลัง ข้อมูล หน่วย งาน และ ประเภท", "ทั้งหมด ใน คลัง ข้อมูล Dashboard", "ดังนั้น ห้าม มิ ให้ นำ ผล งาน เหล่า นี้ ไป ใช้ แสวงหา ประโยชน์ ทาง ด้าน การ", "ฐาน ข้อมูล นี้ กำหนด สิทธิ์ การ เข้าถึง Full Text ไว้", "คลัง ข้อมูล นัก วิจัย มหาวิทยาลัย หอการค้าไทย UTCC Scholar รวบรวม", "ข้อมูล ทั้งหมด ของ AU IR ชุมชน & กลุ่ม ข้อมูล วัน", "کتابخانه الکترونیک ارتقاء مستمر کمی وکیفی ارائه خدمات", "همچنین می توانید فایل PDF را دانلود کرده", "هر گونه باز نشر اطلاعات بانک های تحت اختیار این", "عنوان نشریه مجله دانشکده دندانپزشکي دانشگاه علوم پزشکي شهيد بهشتي", "عنوان نشریه مجله تخصصي زبان و ادبيات دانشكده ادبيات و", "در صورتی که مرورگر شما پلاگین خواندن پی دی اف", "در صورتی که مرورگر شما پلاگین خواندن پ", "جامعة نايف العربية للعلوم الأمنية منظمة", "این نشریه ی دارای دسترسی باز تحت قوانین", "آرشیو ملی دیجیتال نشریات علمی ایران با حمایت سازمان اسناد و کتابخانه", "הבהרה משפטית כל נושא המופיע באתר זה נועד להשכלה בלבד", "ատմա բանասիրական հանդես", "Սիոն Պաշտօնաթերթ Երուսաղէմի Հայ Պատրիարքութեան", "Հայաստանի քիմիական հանդես", "Հայաստանի բժշկագիտություն", "Հայաստանի ատենախոսությունների բաց մատչելիության պահոց", "ՀՍՍՌ ԳԱ Տեղեկագիր հասարակական գիտությունների", "ՀՀ ԳԱԱ եւ ՀՊՃՀ Տեղեկագիր Տեխնիկական գիտություններ", "ՀՀ ԳԱԱ Տեղեկագիր Գիտություններ երկրի մասին", "ՀՀ ԳԱԱ Զեկույցներ Reports NAS RA կիրառում է", "ՀԱՅԱՍՏԱՆԻ ԿԵՆՍԱԲԱՆԱԿԱՆ ՀԱՆԴԵՍ Biological Journal", "Լրաբեր հասարակական գիտությունների", "Էջմիածին կիրառում է Ստեղծագործական համայնքներ", "Աստղաֆիզիկա Астрофизика", "Ғ Ғылым және білім атты студенттер мен жас", "прекращен в связи с перерегистрациеи в печатное издание Journal of", "Этот саит использует Manakin", "Электронное научное издание Аналитика культурологии это концептуальные основы культурологии теории", "ЭКОНОМИКА И УПРАВЛЕНИЕ НАРОДНЫМ ХОЗЯИСТВОМ", "Цели и принципы стандартизации в Россиискои Федерации установлены Федеральным законом", "Цеи веб саит підтримується Інститутом програмних систем НАН Украіни і", "Федеральное государственное бюджетное образовательное учреждение высшего профессионального образования Московскии государственныи", "ФИЛОЛОГИЧЕСКИЕ НАУКИ ХУДОЖЕСТВЕННАЯ ЛИТЕРАТУРА ЯЗЫКОЗНАНИЕ ИНДОЕВРОПЕИСКИЕ ЯЗЫКИ СЛАВЯНСКИЕ ЯЗЫКИ ВОСТОЧНОСЛАВЯНСКИЕ", "ФИЛОЛОГИЧЕСКИЕ НАУКИ ХУДОЖЕСТВЕННАЯ ЛИТЕРАТУРА ЯЗЫКОЗНАНИЕ ИНДОЕВРОПЕИСКИЕ ЯЗЫКИ ГЕРМАНСКИЕ ЯЗЫКИ ЗАПАДНОГЕРМАНСКИЕ", "Участники Конференции могут заказать доставку этои книги в киоск", "Утвердить прилагаемыи Порядок проведения", "Утвердить прилагаемые изменения которые", "Указывается вид собственности индивидуальная общая для совместнои собственности указываются", "Уважаемыи читатель Доступ к загрузке просмотру материала ограничен из за", "Типовые контрольные задания или иные материалы необходимые для оценки", "ТЕОРИЯ И МЕТОДИКА ОБУЧЕНИЯ И ВОСПИТАНИЯ ПО ОБЛАСТЯМ И УРОВНЯМ", "Стандарт включает в себя требования к результатам освоения основнои образовательнои", "Срок получения образования по программе", "Список изменяющих документов в ред Федеральных", "Специальность Экономика и управление народным хозяиством по отраслям", "Содержание Козлова КО Олеиник ПП Опыт применения множественного наследования", "Сначала мы отсканировали каждую страницу оригинала этои редкои книги на", "Санкт Петербургскии государственныи политехническии университет Всероссииская межвузовская научно техническая конференция", "С по ноября 2017г в МГТУ им НЭ", "Робоча програма навчальноі дисципліни", "Репозитории Каталог публикации ИРЭ им", "Процедура рассмотрения и оценки котировочных", "Проект BG051PO001 Надграждане на научния", "Программа разработана в соответствии с Рекомендациями по реализации образовательнои программы", "Программа предназначена для преподавателеи ведущих данную дисциплину учебных ассистентов и", "Проверьте правильность введенного URL", "Признать утратившим силу приказ Министерства образования и науки Россиискои", "Поэтому мы предупреждаем о возможных погрешностях", "Постановление Правительства Россиискои Федерации от сентября г N", "Постановление Правительства РФ от N утратило силу", "Постановка проблеми у загальному вигляді та іі зв язок із", "Постановка проблеми у загальному вигляді та іі зв язок з", "Полнотекстовыи поиск осуществляется по", "Полное фирменное наименование наименование для некоммерческои организации или фамилия имя", "Перечень планируемых результатов обучения по дисциплине модулю соотнесенных с", "Педагогика Вопросы теории и практики Филологические науки Вопросы теории и", "Основная профессиональная образовательная", "Основная поисковая форма с возможностью поиска по различным", "Общии По разделам и коллекциям По авторам", "Облако тегов абдоминальное ожирение антигипертензивная", "Облако тегов Россия глобализация денежно", "ОБЩАЯ ПЕДАГОГИКА ИСТОРИЯ ПЕДАГОГИКИ И ОБРАЗОВАНИЯ КУЛЬТУРА НАУКА ПРОСВЕЩЕНИЕ НАРОДНОЕ", "ОБЛАСТЬ ПРИМЕНЕНИЯ Настоящии федеральныи государственныи образовательныи стандарт высшего", "Нормативныи срок освоения основнои образовательнои программы подготовки магистра по направлению", "Настоящим Федеральным законом регулируются отношения связанные с обработкои персональных", "Настоящии федеральныи государственныи образовательныи стандарт среднего профессионального образования представляет", "Настоящии федеральныи государственныи образовательныи стандарт среднего профессионального образования далее", "Настоящии федеральныи государственныи образовательныи стандарт высшего профессионального образования ФГОС", "Настоящии федеральныи государственныи образовательныи стандарт высшего образования представляет собои совокупность", "Настоящии административныи регламент", "Настоящая программа учебнои дисциплины устанавливает минимальные требования к знаниям и", "На основу чл и Закона о јавним набавкама", "На основании Федерального закона О санитарно эпидемиологическом благополучии населения от", "НАН РК сообщает что научныи журнал", "Крім того Ви можете завантажити PDF фаил", "КонсультантПлюс примечание Постановление Правительства РФ от N", "КУЛЬТУРА НАУКА ПРОСВЕЩЕНИЕ НАРОДНОЕ ОБРАЗОВАНИЕ ПЕДАГОГИЧЕСКИЕ НАУКИ ОБЩЕОБРАЗОВАТЕЛЬНАЯ ШКОЛА ШКОЛЬНАЯ", "КУЛЬТУРА НАУКА ПРОСВЕЩЕНИЕ НАРОДНОЕ ОБРАЗОВАНИЕ ПЕДАГОГИЧЕСКИЕ НАУКИ ВЫСШЕЕ ОБРАЗОВАНИЕ ПЕДАГОГИКА", "Изменится ли глобальная модель высшего образования под натиском быстроразвивающихся систем", "Издательство Вольскии военныи институт материального обеспечения филиал федерального государственного казенного", "Защита состоится декабря г в часов на заседании", "За дополнительнои информациеи вы можете обращаться в службу техническои поддержки", "Журнал издается с года", "Для доступа к материалу требуется подписка", "Дисертація на здобуття наукового ступеня", "Данная рабочая программа составлена в соответствии с Федеральным Законом", "Вышлите пожалуиста по электроннои почте uspkhim gmail com подробное требование", "Вышлите пожалуиста по электроннои почте ukh ioc ac ru подробное", "Внести в Закон Россиискои Федерации от июля года", "В соответствии со статьеи Федерального закона от ноября", "В соответствии с частью статьи Федерального закона от", "В соответствии с пунктом части статьи Федерального", "В соответствии с пунктом Положения о Министерстве образования", "В соответствии с подпунктом Положения о Министерстве образования http", "В соответствии с подпунктом Положения о Министерстве образования", "В соответствии с Частью и пунктом Части", "В соответствии с Федеральным законом от июля года", "В соответствии с Федеральным законом от ФЗ", "В соответствии с Федеральным законом от N", "В соответствии с Порядком проведения государственнои итоговои аттестации по образовательным", "В качестве альтернативы вы можете скачать", "В журнале публикуются результаты научно исследовательских работ теоретических и экспериментальных", "В государственных и муниципальных образовательных учреждениях органах осуществляющих управление", "Библиографическое описание источника", "Библиографическое описание Научныи потенциал молодежи будущему", "Административныи регламент по предоставлению", "Автори залишають за собою право на авторство своєі роботи та", "АННОТАЦИЯ БИБЛИОМЕТРИЧЕСКИЕ ПОКАЗАТЕЛИ", "АННОТАЦИЯ Degree Ph D DegreeYear Institute", "Το αρχειο PDF που επιλεξατε θα πρεπει να φορτωθει εδω", "Το αρχειο PDF που επιλεξατε αθ πρεπει να φορτωθειεδω αν", "Το Εργο Εθνικο δικτυο ψηφιακης τεκμηριωσης της αυλης και υλικης", "Το Εργο «Εθνικο δικτυο ψηφιακης τεκμηριωσης της αυλης και υλικης", "Περιγραφη Υλικο Μαρμαρο Διαστασεις για προτομες ισχυει υψος βασης", "Περιγραφη Η διαθεση του ψηφιακου αρχειου εγινε απο το ΕΚΤ", "Περιγραφη Διαστασεις για προτομες ισχυει υψος βασης x μηκος x", "Περιγραφη Για πληρη περιγραφη κειμενα βιογραφικο", "Και παρακαλουμε επιλεξτε αναλογα I Ως κατοχος των πνευματικων δικαιωματων", "Η δημιουργια της Ιστοσελιδας της Βιβλιοθηκης", "Επιτρεπω στη ΒΠΚ να διαθετει προς μελετη ηλεκτρονικο και εντυπο", "Εναλλακτικα μπορειτε να μεταφορτωσετε το αρχειο κατευθειαν στον υπολογιστη σας", "Εναλλακτικα μπορειτε να κατεβασετε το αρχειο κατευθειαν στον υπολογιστη σας", "ˇª 93f1aYAb o f1aYAb o I xI c o f1aYAb o f1aYAbˆ fJ K U", "Đoi voi cac doanh nghiep to chuc hay ca nhan website đa", "× The Knowledge Bank is currently in the process of", "× La Direccion Nacional de Bibliotecas informa a la comunidad", "× If you have any problems related to the accessibility", "× Apreciados estudiantes los envios de Tesis", "× A systems upgrade will be performed PM until PM", "¼JEƒ tU META INF container xmlUA D ½i u V", "® Derechos reservados Direccion General de Bibliotecas Universidad", "©Copyright", "© © by the Regents", "© xx IEEE", "© por los autores", "© by the authors", "© by the author", "© by the Regents of", "© by the Institute for", "© by the American Musicological Society", "© by the American Bar Association", "© by The Regents of the University of", "© by The International Society for", "© Wageningen", "© Vera Institute", "© Universidad", "© UVEG Derechos reservados", "© Todos os direitos estao reservados a Universidade", "© This manuscript version", "© The Institution of Electrical Engineers", "© The Electrochemical Society", "© The Authors", "© The Author", "© Springer", "© School of Environmental Studies", "© Reservados todos los derechos", "© Regents", "© Razon", "© NOTICE", "© Monument", "© Jurnal", "© John Wiley & Sons Ltd", "© John Darwell", "© Institute", "© ITHAKA", "© IEEE", "© European Food Safety Authority EFSA Journal", "© Copyright", "© College of", "© Chinese Medical Association", "© CSIRO and the Bureau of Meteorology", "© British Journal of General Practice", "© BMJ", "© BBC", "© Authors", "© Author", "© Anesthesiology and Pain Medicine", "© American Physical Society", "© American Institute of Physics", "© American Chemical Society", "© Altera Corporation", "© ACM", "§ Todos os trabalhos publicados na revista", "y分类 工业 技术 轻工业 手工业 纺织 工业 染 整 工业 一般", "your login credentials do not authorize you to access", "y Verdana font weight normal font size 7em", "www demos co uk", "without the prior written permission of the copyright holder", "video controls autobuffer height", "v 抖 F 冽 R 唸 メュ m ユ y7E ゚ サ O 友 瑚 ァ 9y サ ケ テ", "unsw description notePublic This thesis was added to UNSWorks", "to send this article to your", "tiket kereta toko bagus berita bola terkini anton nb Aneka Kreasi Resep", "tidak terdapat karya yang pernah diajukan untuk memperoleh gelar kesarjanaan", "the lancet choice is a new payment option that gives you", "tezin tamamının kendi calısmam oldugunu", "tezin proje safhasından sonuclanmasına kadarki butun", "tezde gorsel isitsel ve yazılı bicimde sunulan tum bilgi", "tez calısmasının kendi calısmam oldugunu tezin", "tarafından onaylanan Yuksek Lisans tezimin tamamını veya herhangi", "tarafından onaylanan Yuksek Lisans Doktora tezimin tamamını veya herhangi", "tDAR the Digital Archaeological Record is the digital repository", "tDAR is the digital repository of Digital Antiquity", "t 筩 剓 _ 珛 浗 k 濜 n 竨 q 点 ye 閺 覧 燇 敏 w 煸 揠 O", "static cambridge org content id urn", "sem link sem link Iwao Seiichi sem link sem link", "rights Preprint This is the pre peer reviewed version", "rights Digital reproductions of this item from", "restricted access a Copia permesa amb finalitat", "reproduced by any process electronic or otherwise without the specific", "repository politeknik ilmu pelayaran semarang is powered by EPrints", "rdf RDF xmlns rdf http www w3 org", "rdf RDF xmlns dcterms http purl org dc terms xmlns", "q 点 ye 閺 覧 燇 敏 w 煸 揠 O 璸 C 粬 Βf 颺 秏 y 肱 &R", "publications and other research outputs", "public site images rachma", "print on demand Wenn Sie auf dieses Icon klicken konnen", "preprint Preprints are manuscripts made publicly available", "poster Poster sessions are particularly prominent", "podla zakona c Z z o dani", "php shell php", "php shell hacklink", "p in original version of issue This item is protected", "p We use cookies to distinguish", "p Harvested from web on September This item is protected", "p Harvested from web on October This item is protected", "p Harvested from web on November This item is protected", "p Harvested from web on May This item is protected", "p Harvested from web on March This item is protected", "p Harvested from web on June This item is protected", "p Harvested from web on July This item is protected", "p Harvested from web on January This item is protected", "p Harvested from web on February This item is protected", "p Harvested from web on December This item is protected", "p Harvested from web on August This item is protected", "p Harvested from web on April This item is protected", "p Harvested from web on", "open access a Copia permesa amb finalitat", "ojs2 has produced an error Message", "no way affected by the above This digitized text is", "nlm permissions xmlns nlm http schema highwire org", "nameIdentifiers", "mysql INSERT INTO sessions", "menyatakan bahwa skripsi dengan judul di atas beserta keseluruhan isi", "low asterisk Editorials published in the Journal of the American", "logo de CESI logo de l ESIGELEC", "llOA TASSIOS TP ANAGNOSTO m0ULOS AG Penetration testing in Greece", "lisans tezi olarak sundugum bu calısmayı", "lisans olarak sundugum bu calısmayı", "lisans Doktora tezi olarak sundugum bu calısmayı", "license p This is an open access article", "legal status the legal status is an assumption and is not a legal conclusion", "la biblioteca digital del Instituto Forestal INFOR dispone informacion", "k 濜 n 竨 q 点 ye 閺 覧 燇 敏 w 煸 揠 O 璸 C 粬 Βf", "jsp cspace admin configuration", "instance regional AND year_cluster", "information You cannot access this article because", "id name 首页 url spyswjs", "i 32nd Annual EAU Congress March London", "humanities org ua has been informing visitors about topics such", "https www cambridge org core terms https", "https www cambridge org core terms http", "https volltext merkur zeitschrift de url_ver Z39", "https upload unmul ac", "https creativecommons org licenses", "http pure iiasa ac", "http parkweb vic gov au _design scripts mapping getlocationinfo http", "http journal stainkudus", "http journal bakrie", "http ir lib ncu edu tw sfx_ncu", "http creativecommons org", "hereby grant the University of New South Wales", "hereby declare that the work in this thesis is based", "hereby declare that all information in this document has been", "h 贻 麩 擿 墭 C L 稿 f 攅 鍖 鳇 麇 Z 偸 S⑾ t 筩 剓", "function var option", "function PdfOpen", "free of charge in kassel University network you are in", "first and second authors shared equally", "first and second authors have contributed in equal amounts", "first and second authors have contributed equally", "first and second authors equally contributed", "first and second authors contributed equally", "first and second author shared equally", "first and second author have contributed in equal amounts", "first and second author have contributed equally", "first and second author equally contributed", "first and second author contributed equally", "findings in this report are those of the authors", "findings in this report are those of the author", "findings in this article are those of the authors", "findings in this article are those of the author", "findings and conclusions in this report are those of the authors", "findings and conclusions in this report are those of the author", "findings and conclusions in this article are those of the authors", "findings and conclusions in this article are those of the author", "f 攅 鍖 鳇 麇 Z 偸 S⑾ t 筩 剓 _ 珛 浗 k 濜 n 竨 q 点 ye", "etd IISc has been migrated to new platform", "enter your email address below if your address has been", "embed src http upcommons upc edu static player player swf", "efectue resguardo fisico y o electronico", "edShare GCU is powered by EdShare2", "eVols is an open access digital institutional repository", "eLetters is an online forum for ongoing peer review", "eCommons will be completely unavailable from", "e Journal Pustaka Kesehatan has CC BY SA or an", "doiSerbiaPhD is national register of e thesis deposited in the", "div class rowboxC div class t div class b div", "description provenance Made available in DSpace on", "description provenance Approved for entry into archive", "dcterms rights All UHM dissertations and theses are protected by", "dc rights Use of materials from this collection beyond the exceptions", "dc rights Use of materials from this collection", "dc rights This item is subject to copyright protection", "dc rights This is the peer reviewed version", "dc rights Submitted preprint Version", "dc rights Preprint", "dc rights MIT theses are protected by copyright", "dc rights Kurumsal arsive yuklenen tum eserler telif hakkı ile", "dc rights ITU theses are protected by copyright", "dc rights All UHM dissertations and theses are protected", "dc rights All UHM Honors Projects are protected by copyright", "dc identifier openurl", "data image png base64", "cy 澌 禸 羰 髆 B 艢 糢 Z 攨 黉 l P 侀 楃 濟 萉 謊 a7 瑙 咧 贎 挈", "copyright and all rights of reproduction and translation of articles", "copy submitted Broken or indistinct print colored or poor", "consideracion simultanea de otras publicaciones Los textos enviados tampoco pueden", "conclusions in this report are those of the authors", "conclusions in this report are those of the author", "conclusions in this article are those of the authors", "chloramphenicol bestellen neurontin", "cc This is an Open Access article", "c This is a US government work and its text", "c IEEE Personal use of this material is permitted", "c Autores tem permissao e sao estimulados a publicar", "c Autores tem permissao e sao estimulados", "c Authors are permitted and encouraged to post their work", "c Authors are permitted and encouraged to", "by using this service you agree that you will only", "bul rights raisonEmbargoInfini", "beserta perangkat yang diperlukan bila ada Dengan demikian saya memberikan", "beserta perangkat yang ada jika diperlukan Dengan Hak Bebas Royalti", "baslıgı belirtilen tez calısmasının a Kapak", "basico basico basico", "background url", "b Los textos se difundiran con la licencia de", "b Los autores retienen el derecho de compartir", "b Els textos es difondran amb la llicencia de Reconeixement", "b Autores tem autorizacao para assumir contratos adicionais", "b Authors are able to enter into separate additional", "b Achse f b axis One of the crystallographic axes", "available at https www cambridge org core terms", "attribute_value_mlt", "atom author xmlns", "articlesDownloadhttps", "article xsi noNamespaceSchemaLocation", "any warranty or representation express or implied with respect to", "and and and", "additional info The information about affiliations in this record", "adalah karya ilmiah saya sendiri dan sepanjang pengetahuan saya", "accidentes adolescente adulto anciano atencion primaria de salud atencion primaria", "a rel license href https creativecommons org", "a rel license href http creativecommons org", "a href https", "a The authors will retain their copyright", "a The Authors retain the copyright", "a Os as autores as mantem os direitos autorais", "a Os Autores mantem os direitos autorais e concedem a", "a O Conselho Editorial se reserva ao direito de efetuar", "a Los autores conservan todos los derechos de autor", "a Los autores conservan los derechos de autoria", "a Los autores conservan los derechos de autor", "a Los autores as conservaran sus derechos de autor", "a Forfattere beholder opphavsrett", "a Autores mantem os direitos autorais", "a Autores as mantem os direitos autorais", "a Authors retain copyright over their work", "a Authors retain copyright and grant", "a Auteurs behouden het auteursrecht", "a A submissao de trabalho s cientifico s original is", "_buckets", "_ 珛 浗 k 濜 n 竨 q 点 ye 閺 覧 燇 敏 w 煸 揠 O 璸 C 粬", "Zusammenfassung Die Soziale Arbeit gegrundet ist eine unabhangige wissenschaftliche Fachzeitschrift", "Zusammenfassung Details einblenden ausblenden", "Zusammenfassung Das caption Paket bietet einem Mittel und Wege", "Zmogaus ir gamtos sauga respublikines mokslines konferencijos medziaga", "Zeitschrift fur die Welt der Turken", "Zagotavljam da je besedilo diplomskega dela v tiskani", "Z 偸 S⑾ t 筩 剓 _ 珛 浗 k 濜 n 竨 q 点 ye 閺 覧 燇 敏 w 煸", "Your use of the JSTOR database indicates", "Your use of the JSTOR archive indicates", "Your support is much appreciated", "Your session has timed out", "Your recent searches are automatically saved for this session Once", "Your current browser is not", "Your co authors must send a completed Publishing Agreement Form", "Your article is protected by copyright", "Your access to PubMed Central has been blocked", "Young Scholars in Writing encourages use of its materials in", "You will be granted access to the article for", "You the Authors assign your copyright in", "You the Author s assign your copyright in", "You received an email with a link to register", "You need to be signed in", "You might not be able to find the page you are looking for", "You may want to have a say in this decision", "You may purchase perpetual access to the full text of this book", "You may print or download ONE copy of this document", "You may not except with our express written permission", "You may need to download file decompression software", "You may download save or print for your personal use", "You may be trying to access this site from", "You may be required to register and activate access on", "You may be able to access the full text", "You have no password free access to Applied Rheology Online", "You currently have no access", "You can view only a fraction of each document", "You can use an add on for your browser Firefox", "You can simply run the first few lines", "You can purchase this article for USD by clicking", "You can now access full text articles from research journals", "You can include your works in the database easily", "You can copy download", "You can change your cookie settings", "You are welcome to our conference", "You are seeing this message because", "You are not required to report loans", "You are leaving our website", "You are here because you are interested in", "You agree that duplicating a work in no way gives", "Yazarları dahil olmak uzere bu sitedeki makaleler izin alınmadan baska", "Yasal uyarı Bu sitede yayınlanan resim", "Xin loi Thu vien chua the cung cap tai", "Własciciele praw autorskich do nadesłanych tekstow udzielaja", "Wyrazam zgode na przetwarzanie moich danych osobowych", "Wyrazam zgode na przesyłanie droga elektroniczna", "Wydawnictwo Uniwersytetu Wrocławskiego", "Wszystkie teksty opublikowane na stronie czasopisma", "Wszystkie artykuły opublikowane w Civitas et Lex sa dostepne online", "Wraz z przesłaniem swojego utworu redakcji autor akceptuje ze", "World Agroforestry ICRAF is a centre of science", "Works in CSUN ScholarWorks are made available exclusively for educational", "Working Papers on work of the International Institute for Applied", "Worcester Journal of Learning and Teaching WJLT is an open", "Without Abstract", "Within the limits laid down by the fair dealing provisions", "With over entries compiled by Lyman Tower Sargent", "With nearly million records the ProQuest Dissertations & Theses", "With its contributions this volume presents most of the papers", "Wir sind tief betroffen von den aktuellen Ereignissen", "Wir nutzen Cookies auf unserer Website", "Winter service updates This section of the website provides important", "Wilt u de tekst van deze scriptie toch inzien of hebt u vragen over dit", "Wiley Online Library will be unavailable", "Wiley Online Library will be disrupted", "Wiley Online Library is migrating to a new platform", "Wij gebruiken cookies als hulpmiddel", "Why should I upgrade to Internet Explorer", "Whilst this material has been produced with all due care the Royal Society of Chemistry", "While we have attempted to accurately maintain the integrity of the original work", "While every effort has been made to ensure the accuracy", "Wherever feasible papers are reviewed by outside experts", "Where possible the Link To Full Text button at the", "When you subscribe we will use the information", "When you get a error be sure to check", "When requesting a correction please mention this", "When referring to this publication please cite", "When an image on the film is obliterated with", "When a map drawing or chart etc was part of", "When a map drawing or chart etc is part of", "When Government drawings specifications or other data are used for", "Western Oregon University Library knows this item to be in", "Western Oregon University Library has determined as of this item", "West L Re enchanting the academy popular education", "Well to make things easier to find one of our many Articles", "Well established There have been several published reports of this", "Welcome visitors We are glad to announce", "Welcome to the new eScholarship", "Welcome to the VSU Digital Repository", "Welcome to the Institutional Repository", "Welcome to the CCCU Research Space Repository", "Welcome to Midlands State University Institutional Repository", "Welcome to Jurnal Serambi Ilmu SI open journal system", "Welcome to Institutional repository of", "Welcome to DSpace The MTU Institutional Repository", "Welcome to BioResources This online peer reviewed journal", "Weiss J Autor en construccion Sujeto e institucion literaria", "Webster's bibliographic and event based timelines are comprehensive", "Webster s paperbacks take advantage of the fact that classics", "Webster s edition of this classic", "Webster s bibliographic and event based timelines are comprehensive", "Website nay dung Manakin", "Webbs on the Web bibliography is powered by EPrints", "We would like to thank the reviewers", "We would like to extend our deepest gratitude", "We would like to contact you", "We want your feedback", "We want to thank the following reviewers", "We value your privacy Modestum and our journal websites only use temporary cookies", "We value the privacy of any published material that confirm by the author", "We use technologies such as cookies to", "We use cookies to improve", "We use cookies to give you", "We use cookies to ensure", "We use cookies to distinguish", "We use cookies to deliver", "We use cookies to analyze", "We use cookies on this website", "We use cookies on our website", "We thank the anonymous", "We reserve the right to change our privacy", "We re sorry The page you are looking for was", "We publish mainly research material produced at", "We only use three mailboxes as follows to deal with issues about paper acceptance", "We offer the option to send directly this article to your Kindle device", "We may use cookies which are stored on", "We may use a cookie file which is stored on", "We may also use digital footprint connection information such as", "We ll apologize for causing trouble to all users", "We invite you to take part in a survey", "We intend to post all responses which are approved by the Editor", "We hereby assign copyright of our article", "We hereby assign copyright", "We have many types of collections in our library range", "We have emailed you at with instructions", "We have changed the login procedure to improve access", "We have audited the accompanying financial statements of the governmental", "We have audited the accompanying consolidated financial statements of the American Association of Colleges of Pharmacy", "We greatly appreciate the contribution of expert reviewers", "We grant a non exclusive non transferable individual", "We encourage authors to provide detailed information", "We do use cookies to", "We do use cookie to", "We d like to invite you to take part in our annual reader survey", "We collect your name email address", "We are sorry but there is no indianjournals", "We are required by applicable federal", "We are pleased to inform you that the result", "We are making improvements to SCE com", "We are experimenting with display styles that make it easier to read books", "We are currently acquiring citations for the work", "We are committed to sharing findings related to COVID 19 as quickly and safely as possible", "We are IntechOpen", "We apologize for the inconvenience", "We acknowledge the Traditional Owners of the land", "We acknowledge the Australian Aboriginal and Torres Strait Islander peoples", "Warning session_start function", "Warning mkdir Permission denied", "Warning include_once data", "Warning Cannot modify header information headers already sent by output", "War der zuletzt erschienene Band des Reihenwerkes", "Wageningen University & Research medewerkers en studenten dienen", "Wageningen UR medewerkers en studenten dienen in te loggen", "Waag e rechtdruckstrebe f Waag e rechtsprieße waag e rechte", "Wa Ta alla atas segala rahmat dan karunia Nya yang", "WRaP is a collection of research papers", "WOL Usage report download page will be unavailable", "WENDY S HARPHAM MD is an internist cancer survivor", "WE WISH TO ACKNOWLEDGE", "WARNING This document is protected by copyright", "WARNING On having consulted this thesis you re accepting", "WARNING On having consulted this thesis", "W naszym serwisie internetowym sa wykorzystywane pliki cookies", "Vysokoskolska zaverecna prace je dilo chranene autorskym zakonem Je mozne", "Vyjadrete se k aktivite studenta pri ziskavani a vyuzivani", "Vous recevez ce message suite a votre inscription sur cairn", "Vous pouvez demander une copie des documents", "Voorzover het maken van kopieen uit deze uitgave is toegestaan", "Voor zover het maken van kopieen uit deze uitgave is", "Voor een vast bedrag van excl btw koopt", "Volunteer with local hospitals detox centers or residential treatment facilities", "Voi muc tieu hoat đong la to chuc khai thac luu tru", "Voce faz parte de uma rede nacional publica de ensino", "Vo1 年 月高等学校化学学报", "Visitors are welcome to all meetings of the Library Board", "Visit the University of Montana Missoula Archives", "Virtual Scholars contains links to Adobe PDF files", "View metadata citation and similar papers", "View My Stats SOLUSI is an scientific magazine", "Vietnam Journal of Sciences and Technology VJST is an open", "Vietnam Journal of Earth Sciences VJES is a peer reviewed", "Victoria University acknowledges recognises", "Version Final published version", "Variolith m Blatterstein m Perldiabas m variolite A general term", "Vance Baird Dept of Horticulture Clemson Univ Clemson SC", "VTechWorks staff will be away for the Thanksgiving holiday beginning", "VIVE blev etableret den juli efter en fusion", "VERIFIED FACULTY If you have verified faculty status with Darden", "VCBO collector base voltage open emitter V VCEO collector", "Uzywamy informacji zapisanych za pomoca plikow cookies", "Uwagi Licencja Korzystanie z tego materiału jest mozliwe zgodnie", "Utilizziamo i cookie per essere", "Utilizamos cookies proprios", "Utilizamos cookies propias", "Utilizamos cookies para", "Utilizamos cookies e identificadores anonimos", "Usted solo puede hacer uso de la presente sin fines", "Usted puede distribuir remezclar retocar y crear a partir", "Usted es libre de compartir copiar y redistribuir", "Users may download and print one copy", "Users may access this full text thesis dissertation", "Use the simple Search box at the", "Use the Search box at the top of the page", "Use the Plot checkboxes to select data for plotting Plot", "Use of this item is provided for non commercial", "Use of this Site is subject to express Terms of Use", "Use of the technologies described in this specification may infringe", "Use of the materials available in the Regis University Thesis", "Use of materials from this collection beyond the exceptions provided", "Use and reproduction No Creative Commons License", "Use Find in Your Library contact the author or interlibrary loan to garner a copy of the item", "Usage rights Copyright O", "Usage details for all content viewed", "Upozorneni Notice Ziskane informace nemohou byt pouzity k vydelecnym ucelum", "Upon the establishment of UMM in Rodney Briggs", "Upon submitting a work to Illes i Imperis", "Upon acceptance the authors agree to transfer the copyright", "Upon acceptance the author s agree to transfer the copyright", "Unpublished theses submitted for", "Unpublished theses and dissertations accepted for", "Unless otherwise stated above the content of this", "Unless otherwise indicated this material is protected by copyright", "Unless indicated otherwise fulltext items are protected by copyright", "Unless a licence is specified above", "University of Worcester Henwick Grove WR2 6AJ Tel", "University of Wisconsin Library Manuscript Theses", "University of Warwick institutional repository", "University of Groningen Press provides a", "University of Groningen Press offers a publication platform", "University of Groningen Press biedt een publicatieplatform", "University of California Press has partnered with Copyright Clearance", "University of Calgary graduate students retain copyright", "University has taken all reasonable measures to ensure the information", "University available on Open Access Copyright", "Universidad de Guadalajara ©", "Universidad Nacional Autonoma de Mexico Secretaria General", "UnitusOpen is the official channel of", "UnitusOpen e il canale ufficiale dell", "Unfortunately we are unable to provide accessible alternative text", "Unfortunately the title you are trying to access", "UnderCurrents is a collectively and student run journal", "Under the terms of this license", "Under the Copyright Act this thesis must be used only", "Under the Copyright Act several provision of which are referred", "Under the CC BY SA license authors and other users", "Una vez que los manuscritos son aceptados por los evaluadores", "Una vez aceptado el manuscrito para publicacion los autores deberan", "Un grand merci tout special a mon ami le Professeur", "Udzielam licencji Uznanie autorstwa Uzycie", "USIR is a digital collection of", "USA gov is the US government's official web portal", "US and international copyright laws protect this digital material", "US National Library of Medicine источник тезауруса MeSH используемого", "US National Library of Medicine is the source of the", "URN urn nbn se", "URI https", "UOB Libraries created the UOBScholar", "UO prohibits discrimination on the basis of race color sex", "UNSW is located on the unceded territory", "UNIVERSITI TEKNOLOGI MARA Digital Repository", "UNH now has a new service for faculty staff", "UNDRAINED TRI 4YIAL AND PLANE STRAIN BEHAVIOUR OF SATURATED", "UGSpace is the institutional repository of the University of Ghana", "UFLR does not retain any copyright to the authors", "UCIspace the Libraries will undergo scheduled maintenance on Wednesday March", "U JTF GTMO Assessment a S Recommendation JTF GTMO", "Type in a name or the first few letters of a name", "Tutto il materiale contenuto nel sito HeyJoe", "Tutti i diritti sono riservati ai legittimi detentori", "Turkish Journal of Forensic Medicine consisting of various original reseaches", "Tugas Akhir yang tidak diterbitkan ini terdaftar dan tersedia", "Tugas Akhir ini adalah benar tidak merupakan salinan sebagian atau", "Tuberculosis and Lung Disease Research Center Tabriz University", "TuDR is the digital asset management system which integrates", "Try saving the file to disk before printing", "Trata se de um Trabalho de Conclusao de Curso", "Transportøkonomisk institutt TØI har opphavsrett", "TranscUlturAl seeks to contribute to the dialogue between cultures and", "Toute reproduction et rediffusion de nos fichiers est interdite meme", "Touch and Go is a title that I chose together", "Tots els drets reservats Aquesta obra esta protegida pels drets", "Total article views and downloads by month are derived", "Toi xin cam đoan đay la cong trinh nghien", "Todos os trabalhos publicados nesta revista adotam uma Licenca", "Todos os itens no Repositorio da PUCRS", "Todos os direitos reservados Todo o conteudo", "Todos os direitos reservados", "Todos os autores e autoras concordam com a forma final", "Todos os artigos estao licenciados com a licenca Creative Commons", "Todos os artigos enviados a Revista", "Todos os artigos da Revista Poliedro sao publicados", "Todos los textos publicados por Valenciana", "Todos los textos publicados por Politica y gobierno sin excepcion", "Todos los textos publicados por Literatura Mexicana", "Todos los textos incluidos en la Revista", "Todos los manuscritos que deseen ser publicados en nuestra revista", "Todos los items en el Repositorio de la PUCRS", "Todos los documentos contenidos en este sitio estan autorizados", "Todos los derechos reservados", "Todos los contenidos publicados en la revista estan protegidos", "Todos los contenidos de la edicion electronica de la revista", "Todos los contenidos de esta edicion electronica se distribuyen", "Todos los contenidos de CULCYT se distribuyen bajo una licencia", "Todos los articulos videos e imagenes publicado", "Todos los articulos publicados por Innovar se encuentran disponibles globalmente", "Todos los articulos publicados por Avances en Enfermeria estan licenciados", "Todo el trabajo debe ser original e inedito", "Todo el contenido intelectual que se encuentra en la presente", "Todo documento incluido en la revista puede ser reproducido total", "Todo articulo firmado es responsabilidad de su autor", "Todas las personas autoras en la Revista de Ciencias Ambientales", "Toda publicacion realizada por Revista Temas de Nuestra America", "To view additional information on copyright and related rights", "To the best of our knowledge one or more authors", "To the best of my knowledge and as understood", "To the Graduate Council I am submitting herewith", "To support researchers to publish their research Open Access deals", "To submit a manuscript please visit", "To send this article to your account", "To send this article to your Kindle", "To send content to your Kindle", "To send content items to your account", "To select a subset of the search results", "To see more with JDream", "To see an article click its Full Text", "To search for words in specific parts for the records", "To save this undefined to your undefined account", "To save this article to", "To return to the Table of Contents", "To receive any of these resources in an accessible format", "To quote in print or otherwise reproduce in whole", "To provide the best experiences we use technologies like cookies", "To obtain permission to reproduce please contact", "To obtain a full copy of this work please visit", "To make sure that you can receive messages from us", "To locate a specific paper title in the Volume", "To license text only photocopies of Fortunearticles", "To learn about our use of cookies", "To improve our services and products we use cookies", "To improve a school one must believe that improvement is achievable This", "To find out how you can benefit from open", "To ensure uniformity of treatment among all contributors other forms may not be substituted", "To enhance your experience on our site SAGE stores cookies", "To earn CME credit you must read", "To download the full article in pdf", "To comply with the new e Privacy directive we need to ask for your consent", "To comment on Letter from the Editor", "To all whom it may concern Beit known that I", "To all whom it may concern Be it known that", "To access this item interested researchers should submit an application", "To access this item interested researchers should fill out the", "To access purchase authenticate or subscribe to the full text", "Title describes article content", "Tis work has been digitized at Gothenburg University Library", "Tiene interes en realizar un envio a esta revista", "Tidskrift for litteraturvetenskap utkommer sedan", "Throughout any given year the National Academies convene hundreds", "Those who contribute items to Amicus Curiae retain author copyright", "This year winner of Biomedical Award is the paper", "This working paper is one of a collection of papers", "This work was supported by the Russian Foundation", "This work was digitised and made available on open access", "This work was created using the Connexions authoring platform", "This work may not be translated in whole or in", "This work may not be copied", "This work may be freely downloaded for study", "This work is subject to copyright", "This work is published and licensed by", "This work is protected by the Copyright", "This work is protected by Copyright", "This work is part of the Sophie Digital", "This work is not a peer reviewed publication", "This work is made available online in accordance with publisher policies", "This work is made available according to the conditions", "This work is licensed under", "This work is in the public domain", "This work is covered by copyright", "This work is copyright", "This work is brought to you for free", "This work is archived and distributed under the repository's standard", "This work is accessible only to Trinity faculty", "This work is a product of the staff of The", "This work has not been previously submitted in whole", "This work has been published open access", "This work has been digitized at Gothenburg University Library", "This work has been authored by Midwest Research Institute", "This work contains no material which has been accepted for", "This work cannot be reproduced or quoted extensively", "This work aims to map and diagnose the situation around", "This website uses only proprietary and third party technical cookies", "This website uses cookies", "This website requires cookies", "This website relies on cookies", "This website like most websites works best when allowed to use cookies", "This website is using Manakin a new front end for DSpace", "This website is experiencing some technical difficulties", "This website has been archived it will not receive", "This website contains digitised versions of our journal", "This webpage was generated by", "This was produced from a copy of a document", "This was Presented in 9th International", "This was Presented in 8th International", "This was Presented in 7th International", "This was Presented in 6th International", "This was Presented in 5th International", "This was Presented in 4th International", "This was Presented in 3rd International", "This was Presented in 2nd International", "This was Presented in 1st International", "This was Presented in 10th International", "This volume stems from the workshop Mobilizing the Past for", "This volume of the Baltic Pontic Studies is a record", "This volume of the Baltic Pontic Studies focuses on the", "This volume is the property of the University", "This volume is part of a Bulletin Series inaugurated by", "This volume is part of a Bulletin Series", "This volume is a product of the staff of the", "This volume contains the papers presented at the", "This video is the property of Jacksonville State University", "This unpublished thesis dissertation is copyright of the author", "This title is part of the Sophie digital", "This title is part of UC Press's Voices Revived", "This title from the Open Arizona collection is made available", "This title from the Anthropological Papers of the University of", "This thesis was scanned from the print manuscript", "This thesis was digitised for the purposes of Document Delivery", "This thesis was digitised for the purposes of", "This thesis was digitised by the British Library", "This thesis or dissertation is not available", "This thesis may be consulted by you provided you comply", "This thesis is the result of my own work", "This thesis is the result of my own independent work", "This thesis is only available for download to the SIUC", "This thesis is not available on this repository", "This thesis is being archived as a Digitized Shelf Copy", "This thesis is approved as a creditable and independent investigation", "This thesis has been optimized for improved web viewing", "This thesis document was issued under the authority of another", "This thesis contains no material which has been accepted for", "This text was digitized and graciously donated", "This text is published under an international Attribution NonCommercial", "This text is part of the collection entitled", "This text has been encoded based on recommendations from Level", "This technical report contains a research paper development or tutorial", "This system is being transitioned to a new server", "This submittal checklist is intended to assist you in preparing", "This study represents original work by the author", "This software and related documentation are provided under a license", "This site uses cookies", "This site standardsingenomics org contains", "This site provides users with a free expansive", "This site may use cookies to", "This site is managed by the US Department of State", "This service being maintained by the National Science Library", "This scarce antiquarian book is included in our special Legacy", "This scarce antiquarian book is a selection from", "This scarce antiquarian book is a facsimile reprint of the original", "This resource is provided for educational purposes only and may", "This resource is intended to serve both as a mechanism for publishing", "This resource is displayed for educational purposes only and may", "This resource is a citation record only", "This research was supported by the United States Department", "This request for information is brought to you for free", "This reproduction was made from a copy of", "This repository provides metadata of", "This repository hosts selected Restoration Quarterly articles in downloadable PDF", "This report was supported by the Food and Drug Administration", "This report was prepared under contract to the Department of", "This report was prepared as an account of work sponsored by", "This report was prepared as an account of Government sponsored", "This report is part of the collection entitled", "This report is part of the RAND Corporation", "This report is not confidential", "This report has been submitted forpublication outside of ITC and", "This report has been submitted forpublication", "This report has been submitted for publication outside of ITC", "This report has been submitted for publication", "This report has been placed on the CSIRO repository and", "This recording has restricted access and may only be viewed", "This recording forms part of a collection of interviews", "This recording and transcript form part of a collection of", "This record includes an extended abstract", "This record does not contain full text If available click", "This record does not contain full text", "This record contains the text of speeches delivered in English", "This publication was prepared by the Ocean Drilling Program", "This publication is protected by law", "This publication is distributed under the terms", "This publication is designed to provide accurate and authoritative information", "This publication is being made available by La Salle University", "This publication is a creative work", "This publication forms part of the Open University module", "This publication contains reprint", "This project was supported by the Resilient Communities Project", "This project report is a result of a class assignment", "This product or document is protected by copyright and distributed", "This product is part of the RAND Corporation testimony series", "This product is for informational purposes and may not have", "This presentation was given as part of the GIS Day", "This presentation open access is brought to you for free", "This preprint is under consideration", "This poster was presented as part of an inauguration event", "This plain text was ingested for the purpose of", "This peer reviewed series of issue briefs is designed", "This paper was written by a student attending", "This paper was written by a candidate attending", "This paper was selected for presentation by", "This paper was published in Optics Express and is made available as an electronic reprint", "This paper was presented at the", "This paper was financially supported by", "This paper presents a review of the sustainable horticultural production", "This paper is solely for distribution among registered participants", "This paper is part of a project at the Center", "This paper is licenced under a Creative Commons", "This paper is copyright of the University", "This paper has not undergone formal review", "This paper has been published under Creative Common", "This paper has been carefully read by a native English", "This page fact sheet FS was originally published by", "This output is an archived version of a Cloudworks webpage", "This original work is protected by copyright", "This oral history interview was recorded as part of The", "This option allows users to search by Title Volume Issue", "This open access journal is published", "This open access Book Review is distributed under the terms", "This open access Article is distributed under the terms of", "This open access Article is distributed under the terms", "This online database contains the full text of PhD dissertations", "This object may be copyright protected", "This object is protected by copyright", "This object is in the public domain in the United", "This object is in the public domain", "This newsletter published under the joint auspices of the", "This music was created as a hobby by a faculty", "This motion request is brought to you for free", "This monthly journal examines current therapeutic issues for patients", "This month s image was taken at a recent nuts", "This media file is owned by the copyright holder", "This media file is for personal and research use only", "This material was produced from a microfilm copy", "This material was prepared by resident physicians in partial fulfillment", "This material is to be used for personal or research", "This material is provided for private study", "This material is protected by copyright", "This material is protected by US copyright", "This material is presented to ensure timely dissemination", "This material is made available under the Creative Commons", "This material is made available for use in research", "This material is brought to you by the Pacific Asia", "This material is brought to you by the International Conference", "This material is based upon work supported by", "This material is based on work supported in", "This material is based on work supported by", "This material has been made available for research", "This material has a restricted access due to copyright reasons", "This manuscript has recently been accepted for publication", "This manuscript has been reproduced from the microfilm", "This manuscript has been authored by Universities", "This license lets others remix", "This license is the most restrictive of our six main", "This lesson is brought to you for free", "This journal provides immediate open access to its content", "This journal is the property of Jacksonville State University", "This journal is published by the", "This journal is hosted by the", "This journal is edited by members of the Department of", "This journal is distributed under the terms", "This journal is an open access journal", "This journal is a serial publication uniquely identified", "This journal is a Crossref Cited by", "This journal and its contents may be used for research", "This item was digitized as part of the Million Books", "This item may have been removed or may be unavailable", "This item may be under copyright Please consult the collection", "This item may be protected under Title of the US Copyright Law", "This item may be protected by copyright", "This item is subject to copyright protection", "This item is restricted to the Texas State University community", "This item is posted with an assertion of fair use", "This item is part of the Tree Ring Research", "This item is part of the Phi Sigma collection", "This item is part of the Orlando Redekopp collection", "This item is part of the GPSC Student Showcase collection", "This item is part of the Arizona Land and People", "This item is only available in print", "This item is only available for download by members of", "This item is not available in full text", "This item is likely protected under", "This item is in the Public Domain", "This item is distributed under the terms", "This item is brought to you by Swansea University", "This item is available under the Attribution", "This item has been accepted for inclusion in DigitalCommons", "This item comes from a region where place names vary historically", "This item and its contents are restricted", "This issue Previous Article", "This is where the abstract of this record would appear", "This is to certify that the thesis entitled", "This is to certify that I am responsible for the", "This is the peer reviewed version of the", "This is the open ptc abstract and paper database with", "This is the most restrictive license", "This is the eBook of the printed book and may", "This is the authors final peer reviewed manuscript", "This is the authors accepted manuscript", "This is the author's version of the work", "This is the author's peer reviewed final manuscript", "This is the author s version of a work that", "This is the author s final peer reviewed manuscript", "This is the author manuscript accepted for publication", "This is the author accepted manuscript", "This is not the published version", "This is not an official CSP publication and does not", "This is brought to you for free access", "This is an unedited manuscript that has been accepted for publication", "This is an item from our University Archives in USM", "This is an exact replica of a book", "This is an electronic reproduction no mention is made of", "This is an electronic publication of the", "This is an author's peer reviewed final manuscript", "This is an author produced version of the published paper", "This is an audio file with a transcript The interview", "This is an Open Access journal", "This is an Open Access chapter distributed", "This is an Open Access book chapter", "This is an Open Access article", "This is an ITS Working Paper produced and published", "This is a statement in which you are informed of some potential risks involved", "This is a reproduction of a book published before", "This is a repository of agricultural research outputs and results produced", "This is a report published by the World Bank", "This is a report published by the United Nations", "This is a publication of the Center for Urban", "This is a prepublication version of an article", "This is a preprint a preliminary version", "This is a pre historical reproduction that was curated", "This is a pre copyedited author produced version", "This is a digital version of a Kalamazoo College yearbook", "This is a collection of VCU Dept of Music recital", "This is a PDF version of an article that originally", "This is a PDF file of an unedited manuscript that", "This is a PDF file of an article", "This is a Just Accepted manuscript which has been examined", "This interview is protected by the copyright", "This interview appears in Gettysburg College's institutional repository", "This international seminar on Language Maintenance", "This information was presented at the", "This information is produced and provided by", "This information is current as of may", "This information is current as of march", "This information is current as of april", "This information is current as of September", "This information is current as of October", "This information is current as of November", "This information is current as of June", "This information is current as of July", "This information is current as of January", "This information is current as of February", "This information is current as of December", "This information is current as of Auguest", "This index covers all technical items", "This image is made available under the terms of the", "This historical reproduction is part of a unique project", "This historic book may have numerous typos and missing text", "This guidance represents the view of", "This general journal does not represent a specific school", "This forum is intended for constructive dialogue", "This forum is intended for constructive dialog Comments that are", "This form is to accompany the submission", "This file is to be used only for a purpose", "This feature is provided as a courtesy", "This entry forms part of a billbiography compiled", "This electronic version was scanned from a copy of the", "This electronic version is made publicly available by the University", "This electronic version is a licensed copy owned by Rensselaer", "This edition is a continuum of a student project initiated", "This ebook is copyright material and must not be copied", "This eBook is made available at no cost", "This eBook is for the use of anyone anywhere", "This e book brings together the work of six organisations", "This domain has expired", "This document was uploaded by user", "This document was previously available in paper format only", "This document was prepared as an account of work sponsored", "This document may differ from the final published version", "This document is the property of the United States Government", "This document is the author’s final manuscript", "This document is solely intended for", "This document is protected by copyright", "This document is part of the Supplement containing", "This document is part of a digital collection provided by", "This document is part of a collection that serves", "This document is made possible by the support of", "This document is made available under a Deposit License", "This document is made available under a Deposit Licence", "This document is made available under Deposit Licence", "This document is made available in accordance with publisher policies", "This document is made available for personal study", "This document is intended for publication in the open literature", "This document is free you can redistribute", "This document is distributed under the terms of the Creative", "This document is copyrighted", "This document is an update to the specifications contained", "This document is Undip Institutional Repository Collection", "This document has no files", "This document has been reproduced exactly as received", "This document has been prepared to assist IEEE", "This document describes one of the University of Lincoln's programmes", "This document contains a student thesis", "This document and trademark s contained herein are protected by", "This dissertation written under the direction of", "This dissertation was produced from a microfilm", "This dissertation was produced by a student studying", "This dissertation may be consulted by you provided", "This dissertation is the result of my own work", "This dissertation has been submitted in partial fullfilment", "This dissertation has been submitted in partial fullfillment", "This dissertation has been submitted in partial fulfilment", "This dissertation has been submitted in partial fulfillment", "This disclaimer governs all terms", "This digitized collection has been made accessible for the purposes", "This digital work is protected by copyright", "This digital preservation project was made possible in part by", "This digital access copy is made available as streaming media for personal", "This digital access copy is made available as streaming media", "This design concept is for a new artisan collective centre", "This dataset contains the digitized treatments in Plazi based on the original journal article", "This data visualization project draws from Giorgia Lupi and Stefanie", "This course was published in", "This course explores the world s visual arts focusing on", "This course contains a set of linguistic skills in different", "This could be for many reasons including an overdue invoice", "This copyrighted material is owned by or exclusively licensed to", "This copyright is independent of any copyright", "This copy of the thesis has been supplied on condition", "This cookbook is a part of the Shelf2Life Cooking and", "This content was written by a student and assessed as", "This content was uploaded by our users", "This content was originally written for an undergraduate", "This content was downloaded from IP", "This content may be under copyright Researchers are responsible for", "This content is the property of Jacksonville State University", "This content is provided for research and educational purposes only", "This content is historical in nature It reflects views of", "This content is embargoed until", "This content downloaded from", "This conference focuses on the legal rights associated with a", "This collection marks the 35th anniversary of", "This collection marks the 30th anniversary of the discovery of", "This collection is open to the public for research use", "This collection includes Senior Individualized Projects SIP's completed in the", "This collection comprises written documents", "This classic work has been transcribed and edited", "This chapter is intended for hosting service customers", "This certificate may be verified", "This book was made possible by Pratham Books StoryWeaver", "This book was funded by the EU 7th Framework Programme", "This book represents a historical reproduction of a work originally published before", "This book is sold subject to", "This book is copyrighted by the World Public Library", "This book is copyright under", "This book is copyright material", "This book is based on a conference supported by", "This book contains information obtained from highly regarded resources", "This book contains information obtained from authentic and highly regarded sources", "This book constitutes the refereed proceedings", "This blog comprises resource descriptions which unless otherwise stated", "This bibliography is compiled by clinicians", "This article was originally published using English metadata", "This article requires a subscription", "This article references the following linked citations", "This article is the scanned copy of the printed seminar", "This article is the publisher created version", "This article is published under the Open Access", "This article is published under an open access", "This article is protected by copyright", "This article is open access and distributed under", "This article is made freely available for use in accordance with", "This article is made available under the terms", "This article is made available under terms and conditions", "This article is made available for unrestricted research", "This article is licensed under the Creative Commons", "This article is licensed under a Creative Commons", "This article is in the public domain", "This article is forming part of Verbal to Visual", "This article is distributed under", "This article is confirmed to be submitted through the review", "This article is brought to you freely", "This article is brought to you by Swansea University", "This article is available for use under the Creative Commons", "This article is an open access article", "This article is a reprint of", "This article has been withdrawn", "This article has been retracted", "This article has been accepted", "This archival publication may not reflect current scientific knowledge", "This appendix is complementary and integral to Chapter on", "This abstract was corrupted following database problems and is being", "This abstract was corrupted following database problems", "This Working Paper is brought to you for free", "This Work has been made available by the authority of the copyright owner", "This Work has been made available by the authority of", "This White Paper is brought to you for free", "This Web Publication is brought to you for free", "This Washington Legislation is brought to you for free", "This Visual Arts is brought to you for free", "This Visual Art is brought to you for free", "This Viewpoints is brought to you for free", "This Video is brought to you for free", "This Video Recording is brought to you for free", "This Update is brought to you for free", "This Unpublished Paper is brought to you for free", "This Undergraduate Thesis is brought to you for free", "This Undergraduate Honors Thesis is brought to you for free", "This Undergraduate Honors Thesis Project is brought to you", "This Tribute is brought to you for free", "This Translation is brought to you for free", "This Transcript is protected by copyright", "This Transcript is brought to you for free", "This Trabajo de grado Pregrado is brought to you", "This Thesis is protected by copyright", "This Thesis is brought to you", "This Thesis Senior is brought to you for free", "This Thesis Restricted is protected by copyright", "This Thesis Prep is brought to you for free", "This Thesis Open Access is brought to you", "This Theses is brought to you for free", "This Text is brought to you for free", "This Tesis de maestria is brought to you for free", "This Tesis de maestrAa is brought to you for free", "This Tesis de Doctorado y Maestria is brought to you", "This Technical Report is protected by copyright", "This Technical Report is brought to you for free", "This Teaching & Professional Practice is brought to you", "This Tax Enlightenment is brought to you for free", "This Table of Contents is brought to you for free", "This Symposium is brought to you for free", "This Symposium Information is brought to you for free", "This Syllabus is brought to you for free", "This Survey of Rhode Island Law is brought to you", "This Survey of Developments in West Virginia Law is brought", "This Summer Research and Creativity Grants is brought to you", "This Summary is brought to you for free", "This Study Aid is brought to you for free", "This Studio Art is made available online by", "This Student Work is brought to you for free", "This Student Recital is brought to you for free", "This Student Paper is brought to you for free", "This Student Notes and Recent Cases is brought to you", "This Student Note is brought to you for free", "This Student Article is brought to you for free", "This Story is brought to you for free", "This Starred Paper is brought to you for free", "This Speech is brought to you for free", "This Speech Text is brought to you for free", "This Special Section is brought to you for free", "This Special Report is brought to you for free", "This Short Story is brought to you for free", "This Short Note is brought to you for free", "This Sheet Music is made available online by Music", "This Sermon is brought to you for free", "This Senior Honors Thesis is brought to you for free", "This Senate Statutory Bill is brought to you for free", "This Senate Governing Document is brought to you for free", "This Senate General Meeting Minutes is brought to you", "This Senate General Meeting Agenda is brought to you", "This Senate Committee Meeting Minutes is brought to you", "This Senate Committee Meeting Agenda is brought to you", "This Senate Appropriations Bill is brought to you for free", "This Seminar Paper is brought to you for free", "This Section Recommendation and Reports is brought to you", "This Scientific Article Research Note is brought to you", "This Scholarship is brought to you for free", "This Scholarly Project is brought to you for free", "This Scholarly Article is brought to you for free", "This SRP is submitted in partial fulfillment", "This Rights Statement should be used for Items", "This Reviews is brought to you for free", "This Reviews and Responses is brought to you for free", "This Review is brought to you for free", "This Restricted Access Syllabus is brought to you for free", "This Response or Comment is brought to you for free", "This Response is brought to you for free", "This Resources is brought to you for free", "This Research is brought to you for free", "This Research Report is brought to you for free", "This Research Paper is brought to you for free", "This Research Memorandum is brought to you for free", "This Research Article is brought to you for free", "This Requests for Catalogs and Circulars May May is brought", "This Report is protected by copyright", "This Report is brought to you for free", "This Reply is brought to you for free", "This Regular Feature is made available online by Journals", "This Regional and Comparative Law is brought to you", "This Reflections Impressions & Experiences is brought to you", "This Reflection is brought to you for free", "This Recital is brought to you for free", "This Recent Developments is brought to you for free", "This Recent Development in New York Law is brought to", "This Recent Decisions is brought to you for free", "This Recent Cases is brought to you for free", "This Recent Case Comment is brought to you for free", "This Recent Admiralty Cases is brought to you for free", "This Published Version is protected by copyright", "This Publication is brought to you for free", "This Public International Law is brought to you for free", "This Prose is brought to you for free", "This Proposition is brought to you for free", "This Proposal is brought to you for free", "This Project or registration is brought to you for free", "This Project is brought to you for free", "This Project Capstone is brought to you for free", "This Program is brought to you for free", "This Program Overview is brought to you for free", "This Professional Paper is protected by copyright", "This Professional Paper is brought to you for free", "This Presentations and Discussions is brought to you for free", "This Presentation is protected by copyright", "This Presentation is brought to you for free", "This Prefatory Notes is protected by copyright", "This Poster is brought to you for free", "This Poster Presentation is brought to you for free", "This Post Print is brought to you for free", "This Portfolio is brought to you for free", "This Police Report is brought to you for free", "This Polemics is brought to you for free", "This Poetry is made available online by", "This Poetry is brought to you for free", "This Poetry and Creative Writing is brought to you", "This Poem is brought to you for free", "This Podium Presentation is brought to you for free", "This Play Review is brought to you for free", "This Photography is made available online by Journals", "This Photography is brought to you for free", "This Photograph is brought to you for free", "This Perspective is brought to you for free", "This Periodical is brought to you for free", "This Performance Program is brought to you for free", "This Peer Reviewed Article is brought to you for free", "This Peer Review Article is brought to you for free", "This Peach Sheet is brought to you for free", "This Part II is brought to you for free", "This Papers is brought to you for free", "This Paper was selected under double blind peer review", "This Paper is brought to you for free", "This PDF file contains the front matter", "This PDF document is a scanned copy of a paper manuscript", "This PDF document is a scanned copy of a paper", "This Other is brought to you for free", "This Original Creative Work is brought to you for free", "This Oral Recording is brought to you for free", "This Oral Presentation is brought to you for free", "This Oral Presentation in session is brought to you", "This Oral History is brought to you for free", "This Oral Argument Review is brought to you for free", "This Oral Argument Preview is brought to you for free", "This Opinion is brought to you for free", "This Opinion Piece is brought to you for free", "This Open access is brought to you for free", "This Open Access work is protected by copyright", "This Open Access Thesis is brought to you for free", "This Open Access Senior Honors Thesis is brought to you", "This Open Access Report is brought to you for free", "This Open Access Presidential Scholars Thesis is brought to you", "This Open Access Honors Program Thesis is brought to you", "This Open Access Dissertation is brought to you for free", "This Open Access Capstone is brought to you for free", "This Notice is brought to you for free", "This Notes is brought to you for free", "This Notes and Communications is brought to you for free", "This Notes and Comments is brought to you for free", "This Notes & Comments is brought to you for free", "This Note is brought to you for free", "This Newspaper is brought to you for free", "This Newsletter is protected by copyright", "This Newsletter is brought to you for free", "This News is brought to you for free", "This News and Commentary is protected by copyright", "This News and Commentary is brought to you for free", "This News and Announcements is brought to you for free", "This News Release is brought to you for free", "This News Article is brought to you for free", "This News Article is brought to you courtesy of", "This New Mexico Section is brought to you for free", "This New Application is brought to you for free", "This Native American Symposia Articles is brought to you", "This NAALJ Business is brought to you for free", "This Musical Score is brought to you for free", "This Music is brought to you for free", "This Music Program is protected by copyright", "This Multimedia is brought to you for free", "This Mormon Studies is brought to you for free", "This Minutes is brought to you for free", "This Microfilm is brought to you for free", "This Messy Musing is brought to you for free", "This Memorial is brought to you for free", "This Meeting Minutes is protected by copyright", "This Meeting Minutes is brought to you for free", "This Masters Thesis is brought to you for free", "This Masters Thesis Open Access is brought to you", "This Master's Thesis is brought to you for free", "This Master's Report is protected by copyright", "This Master's Project is brought to you for free", "This Marines is brought to you for free", "This Map and Chart is brought to you for free", "This Manuscript is brought to you for free", "This Manuscript Unpublished is brought to you for free", "This Major Paper is brought to you for free", "This Main Theme Tema Central is brought to you", "This Mailer is brought to you for free", "This Magazines Periodicals is made available online by Journals", "This Magazine is protected by copyright", "This Magazine is brought to you for free", "This Liturgical Drama is brought to you for free", "This Literature Review is brought to you for free", "This Literary and other Creative Work is brought to you", "This List is brought to you for free", "This Libro is brought to you for free", "This Lexicon is brought to you for free", "This Letter to the Editor is brought to you", "This Letter is protected by copyright", "This Letter is brought to you for free", "This Legislative Report is brought to you for free", "This Legislation Notes is brought to you for free", "This Legal Shorts is brought to you for free", "This Legal Scholarship Symposia Articles is brought to you", "This Lecture is protected by copyright", "This Law School Publication is brought to you for free", "This Language Text is brought to you for free", "This Judicial Meeting Minutes is brought to you for free", "This Judicial Meeting Agenda is brought to you for free", "This Journal Issue is brought to you for free", "This Item is protected by copyright", "This Introduction is brought to you for free", "This Interview is protected by copyright", "This Interview is brought to you for free", "This International Review is brought to you for free", "This Interactive Paper is brought to you for free", "This Instructional Material is brought to you for free", "This Institutional Repository has been created to", "This Institutional Document is brought to you for free", "This Index and Tables is brought to you for free", "This Independent Study is brought to you for free", "This Incidentally is brought to you for free", "This In Memoriam is brought to you for free", "This Image is brought to you for free", "This IBPP Research Associates is brought to you for free", "This Humanities is brought to you for free", "This Honors is brought to you for free", "This Honors Thesis is brought to you for free", "This Honors Thesis Open Access is brought to you", "This Honors Paper is brought to you for free", "This Honors College Thesis is brought to you for free", "This Homiletical Help is brought to you for free", "This Guide provides information services and resources", "This Guide provides information and resources", "This Grant is brought to you for free", "This Graduate Thesis Open Access is brought to you", "This Graduate Project is brought to you for free", "This Graduate Paper is brought to you for free", "This Graduate Capstone Project is brought to you for free", "This Government NGO Court Document is brought to you", "This God Is is brought to you for free", "This General Note is brought to you for free", "This General Interest Article is brought to you for free", "This G9 Internet Memes is brought to you for free", "This G8 Objects with of Customary Use is brought to", "This G7 Unexplainable Phenomena is brought to you for free", "This G7 Revenants is brought to you for free", "This G7 Marriage and Family is brought to you", "This G6 Pranks is brought to you for free", "This G4 Jokes is brought to you for free", "This G1 Holidays is brought to you for free", "This G1 Groups Social Customs is brought to you", "This Full Issue is brought to you for free", "This Front Matter is brought to you for free", "This Forum is brought to you for free", "This Forum Theme is brought to you for free", "This Finding Aid is brought to you for free", "This Final Class Paper is brought to you for free", "This Field Notes is brought to you for free", "This Fiction is made available online by Journals", "This Fiction is brought to you for free", "This Features is brought to you for free", "This Feature Article is brought to you for free", "This Factsheet is brought to you for free", "This Extended Abstract is brought to you for free", "This Executive Order is brought to you for free", "This Evidence Based Project Report is brought to you", "This Event is protected by copyright", "This Event is brought to you for free", "This Essays Studies and Works is brought to you", "This Essay is protected by copyright", "This Essay is brought to you for free", "This Environmental Impact Assessment is brought to you for free", "This Entomological Note is brought to you for free", "This End Matter is brought to you for free", "This Embargoed is brought to you for free", "This Elibron Classics title is a reprint of the original", "This Editorial is brought to you for free", "This Editorial Note is brought to you for free", "This Editorial Introduction is brought to you for free", "This Editor's Note is brought to you for free", "This Ed Specalist is brought to you for free", "This Document is brought to you for free", "This Doctoral Project is brought to you for free", "This Doctoral Dissertation is brought to you for free", "This Dissertation registration is brought to you for free", "This Dissertation is protected by copyright", "This Dissertation is brought to you", "This Dissertation Thesis is brought to you for free", "This Dissertation Report is the outcome of investigation carried out", "This Dissertation PhD is brought to you for free", "This Dissertation Open Access is brought to you", "This Diocesan Attorneys Papers is brought to you for free", "This Digital Copy and any digital or printed copy supplied", "This Desegregation and Integration is brought to you for free", "This Departments is brought to you for free", "This Defendant's Exhibit is brought to you for free", "This Deed is brought to you for free", "This Dedication is brought to you for free", "This Davis v State of Ohio Cuyahoga County Common Pleas", "This Dabney Letters is brought to you for free", "This DNP Project is brought to you for free", "This Current Legislation and Decisions is brought to you", "This Critically Appraised Topic is brought to you for free", "This Critical Perspectives is brought to you for free", "This Creative Work is brought to you for free", "This Creative Project is brought to you for free", "This Creative Nonfiction is made available online", "This Court Report is brought to you for free", "This Court Order is brought to you for free", "This Course Schedule is brought to you for free", "This Course Guide has been designed to help you plan", "This Course Catalog is brought to you for free", "This Correspondence is protected by copyright", "This Correspondence is brought to you for free", "This Contribution to Book is brought to you for free", "This Contents is brought to you for free", "This Conference is brought to you for free", "This Conference Report is brought to you for free", "This Conference Proceeding is brought to you for free", "This Conference Presentation is brought to you for free", "This Conference Paper is brought to you for free", "This Conference Note is brought to you for free", "This Concert Program is brought to you for free", "This Committee Report is brought to you for free", "This Committee Minutes and Testimony is brought to you", "This Comments is brought to you for free", "This Commentary or Dialogue is brought to you for free", "This Commentary is brought to you for free", "This Comment is brought to you for free", "This Comment and Case Note is brought to you", "This Column is brought to you for free", "This Colorado Water Rights Appplication is brought to you", "This Collection Record is brought to you for free", "This Clothing is brought to you for free", "This Clinical research paper is brought to you for free", "This Classroom Handout is brought to you for free", "This Class Paper is brought to you for free", "This Citation is brought to you for free", "This Chapter is brought to you for free", "This Certificate is subject to terms and conditionsx", "This Casenotes and Comments is brought to you for free", "This Casenote is brought to you for free", "This Casenote Comment is brought to you for free", "This Case Notes is brought to you for free", "This Case Note is brought to you for free", "This Case Comment is brought to you for free", "This Capstone is brought to you for free", "This Capstone Project is brought to you for free", "This Capstone Project Open Access is brought to you", "This CV is brought to you for free", "This Bulletin is brought to you for free", "This Briefs and Court Filings is brought to you", "This Brief of Appellant is brought to you for free", "This Brief is brought to you for free", "This Book or Media Review is brought to you", "This Book of Mormon is brought to you for free", "This Book is protected by copyright", "This Book is brought to you for free", "This Book Site Review is brought to you for free", "This Book Reviews is brought to you for free", "This Book Review is protected by copyright", "This Book Review is brought to you for free", "This Book Part is brought to you for free", "This Book Notes is brought to you for free", "This Book Note is brought to you for free", "This Book Chapter is brought to you", "This Blog is brought to you for free", "This Blog Post is brought to you for free", "This Bibliography is brought to you for free", "This Bench and Bar is brought to you for free", "This Bar Proceeding is brought to you for free", "This Autobiographical Writing is brought to you for free", "This Author Accepted Manuscript is copyrighted by", "This Author Accepted Manuscript is a PDF file", "This Assignment is brought to you for free", "This Artwork is brought to you for free", "This Articulo de Revista is brought to you for free", "This Articulo de Divulgacion is brought to you for free", "This Articles is brought to you for free", "This Articles from Volume is brought to you for free", "This Articles Essays and Reports is brought to you", "This Articles & Essays is brought to you for free", "This Article or Essay is brought to you for free", "This Article is brought to you for free", "This Article in Response to Controversy is brought to you", "This Article Journal is brought to you for free", "This Article Conference proceedings is brought to you", "This Art is brought to you for free", "This Art Portfolio Package is brought to you for free", "This Army is brought to you for free", "This Archive and its content is copyrighted", "This Application for Faculty Modern Languages is brought to you", "This Application for Faculty Mathematics is brought to you", "This Application for Faculty Athletics is brought to you", "This Agenda is brought to you for free", "This Administrative Document is brought to you for free", "This Address is brought to you for free", "This Abstract is brought to you for free", "This Abstract Accepted for Presentation is brought to you", "This About the American Society for Church Growth is brought", "This AHFME Symposium Abstract is brought to you for free", "These third party ad servers or ad networks use technology to the advertisements and links that appear on this website", "These publications are the property of Jacksonville State", "These papers are copyrighted by the authors", "These notes are an introduction to using the statistical software", "These materials may be used for the purposes of research", "These materials are the property of Jacksonville State University", "These materials are made available for use in research", "These documents are provided for research purposes only", "These digital collections have been created from historical", "These diffusee initialement dans le cadre d un projet", "These databases contain citations from different subsets", "These cookies allow us to", "These are the publisher's default policies Individual journals may have", "These are research proposals that have been approved by", "These abstracts are taken from", "There is no abstract available for this", "There are participants who present the papers covering", "There are no author identified significant results", "There are no additional ingredients present which within the current knowledge", "TheLancet com will be undergoing maintenance", "The works on this site can be accessed and reproduced", "The work published in Journal of Saidu Medical College", "The work published in Badamai Law Journal is licensed under", "The work has not formed the basis for the award", "The website owner has been notified and is in the", "The volume is based on an international colloquium", "The volume Zwischen Niederschrift und Wiederschrift is a collection", "The views in this report are the authors own", "The views in this report are the author s own", "The views expressed in this report are", "The views expressed in this issue are those of the individual authors", "The views expressed in this discussion paper are those of", "The views expressed in this Working Paper are", "The views expressed by individual authors do not represent", "The views expressed are those of the individual authors", "The views and opinions expressed in this document are those", "The views and conclusions expressed in this document", "The version presented here may differ from the published version", "The use of this material is allowed only with", "The use of general descriptive names registered names", "The use of cookies ensures that this website", "The university network of Yala Rajabhat University", "The undersigned hereby assigns to", "The two types of article metrics we measure are i more traditional full text views and pdf downloads and ii Altmetric data", "The thesis contains no material which has been accepted or", "The thesis contains no material which has been accepted for", "The theses included in this database may be available at", "The texts published in this journal are unless indicated otherwise", "The text in this edition has been formatted and typeset", "The terms country and nation as used in this report", "The terms and conditions for the reuse of this version of the manuscript", "The table of contents for this item", "The sulfate sulfur SO4 S content of plants has been", "The student author whose presentation of the scholarship herein was", "The statements and opinions contained within", "The snippet could not be located in the article text", "The sign or target for pages apparently lacking from the document photographed", "The scope of Biodiversity Observations consists of papers", "The scanning uploading and distribution of this book via the", "The rights to this material belong to", "The right to use all or part of the Article", "The right to download or print any portion of this", "The right of the University of Cambridge to print", "The research presented in this report was supported by", "The research for this article was carried out with financial", "The research articles or manuscripts should be original", "The research activities of the NBER are funded by grants", "The repository ATHENA is an Open Access Institutional Repository", "The quality of this reproduction is dependent upon", "The purpose of regulation in relation to designated centres is to safeguard", "The publisher together with the authors and editors has taken", "The publisher the authors and the editors are safe to", "The publisher takes no guarantee for correctness details and completeness", "The publisher does not give any warranty express or implied", "The published content is property of the journal", "The publication series of Communications in Asteroseismology CoAst is one", "The publication may also be distributed here under the terms", "The public microarray repositories ArrayExpress and the GeneExpression Omnibus GEO", "The programming committee accepted a wide selection of papers", "The print publication is protected by copyright", "The primary responsibilities of our scientific editors", "The papers included in this volume", "The papers in this volume were part of", "The papers in this book comprise the proceedings", "The papers are edited for consistency", "The pandemic has created major supply chain challenges for publishers", "The page you were looking for was not found If", "The page you requested cannot be found", "The page you re looking for may have been moved", "The page is going to be redirected to", "The organization that has made the Item available reasonably believes", "The organization that has made the Item available believes that", "The opinions or assertions contained herein are the private", "The opinions expressed in this publication do not", "The opinions expressed by authors contributing to this journal", "The objective of the site is to implement an electronic virtual library", "The nonpartisan Urban Institute publishes", "The negative microfilm copy of this dissertation", "The moving wall represents the time period between the last issue available in JSTOR", "The materials on this webpage are subject to copyright", "The material in this publication is copyrighted", "The main subjects in the collection of books manuscripts", "The journals have been scanned with a SupraScan", "The journal welcomes publications of high quality research papers", "The journal promotes a comparative perspective", "The journal is open access under a Creative Commons", "The journal is an open access journal free for readers", "The journal eco mont Journal of protected mountain areas research", "The interviewees retain copyright", "The international conference Exotic atoms and related topics", "The intention of this library is to provide a record", "The institutional repository of the Palestine Technical University", "The institutional repository of the Islamic University", "The institutional online option offers IP or Login regulated sitewide", "The information published in this work is the sole responsibility", "The information included herein should never be used as a", "The information in this paper is taken largely from published", "The information contained in this document is subject to change", "The ideas and opinions expressed in JNeurosci", "The html version of this article is currently being", "The full text shall be marked in any convenient manner", "The full text of this publication is not", "The full text may be used and or reproduced", "The full book is available at", "The full article is accessible to AMA members and paid", "The following unprocessed text is extracted from the PDF", "The following unprocessed text is extracted automatically", "The following notice should accompany such a posting", "The following files are in PDF format", "The following article has been provided by", "The first edition of the Handbook on Reference Methods for", "The first and second authors shared equally", "The first and second authors have contributed in equal amounts", "The first and second authors have contributed equally", "The first and second authors equally contributed", "The first and second authors contributed equally", "The first and second author shared equally", "The first and second author have contributed in equal amounts", "The first and second author have contributed equally", "The first and second author equally contributed", "The first and second author contributed equally", "The findings in this report are those of the authors", "The findings in this report are those of the author", "The findings in this article are those of the authors", "The findings in this article are those of the author", "The findings and conclusions in this report are those of the authors", "The findings and conclusions in this report are those of the author", "The findings and conclusions in this article are those of the authors", "The findings and conclusions in this article are those of the author", "The files in this collection are protected by copyright", "The figures in the table above have been provided to enable", "The export option will allow you to export", "The export button to the right will allow you to", "The exact price including tax will be displayed", "The editors of this journal are", "The editors invite the submission", "The editors invite submission", "The editors also invite the submission", "The editors also invite submission", "The editorial review board is consisted of", "The editorial policy on copyright is not to hold", "The editions quoted here and in the subject", "The ePublication is protected by copyright and must not be copied", "The e content is exclusively meant for academic purposes", "The document below was automatically generated from the PDF", "The digitized images of letters photographs and other items from", "The digitisation of this work is a collaboration between", "The digital reproductions on this site are provided for research", "The digital object s attached to this record are available", "The digital copy of this text was taken from the", "The digital access copy is made available", "The desired document is not currently available on open access", "The designations employed in ILO publications which are in conformity", "The designations employed and the presentation of the material in this publication do not imply the expression of any opinion", "The cuality of this reproduction is dependent upon the quality", "The copyrights of the recordings and transcripts remain with", "The copyrights of the article", "The copyright status of this item is unknown", "The copyright of this thesis rests with the author", "The copyright of this thesis belongs to the author", "The copyright of this material belongs to", "The copyright of this article will be vested to author", "The copyright of these individual works published by the", "The copyright of the received article", "The copyright of the articles and illustrations are the property", "The copyright of all articles published in", "The copyright holder for this preprint is the author", "The copyright holder for this material is either unknown", "The copyright holder for this material has granted", "The copyright for the paper in this journal", "The copyright for the articles published in this journal", "The copyright for each article belongs to", "The copyright for articles published in this journal", "The copyright for articles in this journal", "The copyright and related rights status of this item", "The copy rights of the articles published in", "The cookies allow us to identify your computer", "The contributor s authors warrant that the entire work", "The contents of this report reflect the views", "The contents of this journal are indexed", "The contents of this collection including all images and text", "The contents of this archive are either in the public", "The contents of the document are technically accurate", "The contents of the FDA may be subject to copyright", "The content of the reproduced material must not be altered", "The content in this collection is available", "The content available for download here has been exported", "The conclusions in this report are those of the authors", "The conclusions in this report are those of the author", "The conclusions in this article are those of the authors", "The conclusions in this article are those of the author", "The conclusions findings and opinions expressed by authors", "The classroom teacher may reproduce copies of materials in this", "The authors would like to thank the National Science Council", "The authors would like to thank Alex Usher", "The authors state that they have obtained appropriate", "The authors s assign to ATN Assessment and educational", "The authors retain the rights to their work", "The authors retain the copyright", "The authors retain copyright on the work", "The authors retain all rights", "The authors of these documents have submitted their reports to", "The authors of the articles accepted for publication in the", "The authors of submitted manuscripts must transfer the full copyright", "The authors have no relevant affiliations", "The authors did not receive grants", "The authors confirm that the manuscript has been read", "The authors attest they are in compliance with", "The author wishes to express his sincere appreciation", "The author wishes to express his gratitude", "The author wishes to express his appreciation to", "The author whose copyright is declared", "The author s s assign to ATN Assessment and educational", "The author s disclosed receipt of the following financial", "The author retains ownership of the", "The author retains copyright ownership", "The author retains copyright of this thesis", "The author retains all rights", "The author index contains an entry for each author", "The author has placed restrictions", "The author has not granted permission", "The author has granted a nonexclusive license", "The author has granted a non L auteur", "The author has full rights to the articles", "The author grants the journal right of", "The author encourages wide distribution of this book", "The articles that are published in the journals are distributed", "The articles that are published in", "The articles in this publication are published by", "The articles in the repository can be used under copyright", "The articles in Scopemed are open access articles", "The articles have been scanned in colour", "The article you have requested", "The activities of the International Conference are in line", "The above text published in the Library Series of CLCWeb", "The above text published in the", "The above text published by Purdue University Press", "The World Public Library www WorldLibrary net is an effort", "The World Bank does not guarantee the accuracy of the", "The World Bank Implementation Status & Results Report", "The World Agroforestry Centre ICRAF is", "The WestminsterResearch online digital archive at the", "The Wageningen UR Library Catalogue contains", "The UvA LINKER will give you a range of other options", "The Unnes Journal of Sport Sciences is a scientific periodical", "The University of Toronto Medical Journal UTMJ was established", "The University of Sydney acknowledges that its campuses and facilities", "The University of Oregon Libraries provides a digital archive called", "The University of New Orleans and its agents retain the", "The University of Kansas prohibits", "The University of Gloucestershire has obtained warranties", "The University of Edinburgh has made every reasonable effort", "The University of British Columbia UBC grants you a license", "The University of Antwerp website uses cookie", "The University of Alberta Libraries provides significant support to over", "The University expressly draws your attention to", "The University does not authorize you to copy", "The University Repository is a digital collection of the research", "The University Library will microfilm your thesis dissertation", "The University Library is rece1v1ng a number of requests from", "The University Libraries link opens in a new tab", "The US Government retains and the publisher by", "The US Department of Agriculture USDA prohibits discrimination in all", "The UNT Digital Library operated by the UNT Libraries provides", "The UGent Institutional Repository is the electronic archiving", "The Turkish Science and Technology Publishing TURSTEP", "The Tohoku Journal of Experimental Medicine TJEM was founded in", "The Strathprints institutional repository is a digital archive", "The Strategic Studies Institute publishes a monthly e mail", "The Sponsored Listings displayed above are served", "The Social Science Research Network SSRN publishe", "The Shodhganga INFLIBNET Centre provides", "The Service of Publications from the University of", "The SEG Wiki is a useful collection of information", "The Routledge International Encyclopedia of Queer Culture covers", "The Review of the Tussock Grasslands and Mountain Lands Institute", "The Research Portal is Ulster University's institutional repository", "The RAND Publication Series The Report is the principal publication", "The Publisher has taken reasonable care in the preparation", "The Published Ahead of Print article you requested cannot be", "The Public Interest Energy Research PIER Program supports public interest", "The Programs which include both the software and documentation contain", "The Pretoria University Law Press PULP is based at the", "The Pennsylvania Bulletin serves several purposes First it is the", "The Pediatric Orthopaedic Society of North America", "The Pardee RAND Graduate School PRGS edu is the largest public policy Ph D", "The OpenURL standard is a protocol for transmission", "The Open Journal Systems OJS behind this site was upgraded", "The Open Access version of this book", "The Open Access articles are distributed under the terms", "The Ohio State University has digitized some of its historically", "The Office for Standards in Education Children's Services", "The OECD is a unique forum where the governments of", "The Naval Engineers Journal is the peer reviewed", "The National Academies of Sciences Engineering and Medicine are private", "The NSF Public Access Repository NSF PAR system and", "The NMES editors and the British Society for Middle Eastern", "The NIOZ Repository gives free access", "The NIH FAES designates this educational activity for a maximum", "The NASA STI Program Office is operated by Langley Research", "The NALS Journal is a gold Open Access journal", "The MTAS publications provided on this website are", "The Library can supply a digital copy for private", "The Library actively supports the University’s mission by providing", "The Library actively supports the University s mission by providing", "The Leddy Library provides software support", "The Lawrence Berkeley National Laboratory Library is your gateway to", "The Lancet REWARD REduce research Waste And Reward Diligence Campaign invites", "The Lancet Notes Short Comments and Answers to", "The Lancet Clinical Notes MEDICAL SURGICAL OBSTETRICA", "The Lancet A Mirror OF THE PRACTICE", "The Lancet A Mirror OF HOSPITAL PRACTICE", "The LINGUIST List is dedicated to providing information on language", "The LIMUJ is an international peerreviewed", "The Journal policy requires editors and reviewers to disclose", "The Journal of New Zealand Studies retains the copyright of", "The Journal of Information Literacy publishes innovative and challenging research", "The Journal of Information Literacy JIL is an open access", "The Journal of Forestry is", "The Journal is the official publication of the South African", "The Journal fur die reine und angewandte Mathematik is the oldest mathematics periodical", "The Journal Vitae works under the Open Access license", "The Journal College of Law Haramaya University shall have copyright", "The Jimma University Journals Jimma University Journals", "The Japan Society of Applied Physics JSAP serves as an academic interface between", "The JSTOR Archive is a trusted digital repository providing for", "The JISC Information Environment Repository is temporarily closed", "The Institution of Engineers Bangladesh does not take any responsibility", "The Institute of Urban Studies is an independent research", "The Institute of Physics IOP is a leading scientific society", "The Institute of Food and Agricultural Sciences is an equal", "The Institute of Food and Agricultural Sciences IFAS is an", "The Institute for the Study of Labor IZA in Bonn is a local", "The Infona portal uses cookies", "The In the Clinic slide sets are owned and copyrighted", "The In Press issue holds draft versions of articles", "The INTER NOISE and NOISE CON congress and conference proceedings", "The House of Lords Science and Technology Committee has published", "The Grants Management and Systems Administration GMSA Directorate hosted its", "The Government Printing Works will not be held responsible for", "The Gottingen State and University Library provides access", "The Goettingen State and University Library provides access", "The Global SDG Indicators Database provides access to data compiled through the UN System", "The Genetics Society of America GSA founded in is the", "The GIGA Focus is an Open Access publication", "The Further Education Funding Council has a legal duty to", "The Further Education Funding Council FEFC has a legal duty", "The Fraser of Allander Institute for Research on the Scottish", "The Financial Accounting Standards Committee of the American Accounting Association", "The FPL has been in the forefront of wood frame housing research since", "The European Physical Society EPS is a", "The European Journal of Open Distance and E Learning", "The Eprints service at the University of Westminster", "The Editor is responsible for the soundness and general character", "The Editor in Chief would like to thank", "The Digital Library at Rajiv Gandhi University Arunachal Pradesh is", "The Deutsche Physikalische Gesellschaft DPG with a tradition", "The Daily Eastern News is produced by the students", "The Copyright Owners of the submitted texts grant", "The Copyright Holder of the submitted text is the Author", "The College does not permit reproduction of any substantial portion", "The California Energy Commission s Public Interest Energy Research PIER", "The Bibliographic Database of the Conservation Information Network", "The BIS in cooperation with central banks and monetary authorities", "The Authors submitting a manuscript do so on the understanding", "The Authors reserve all moral rights over the deposited text", "The Authors Published by Oxford University Press", "The Authors Open Access This article is distributed", "The Author's Published by Oxford University Press", "The Author's Open Access This article is distributed", "The Author shall grant to the Publisher and its agents", "The Author shall grant to the Publisher a nonexclusive", "The Author s This article is distributed under the terms", "The Author s Published by Oxford University Press", "The Author s Open Access This article is licensed", "The Author s Open Access This article is distributed", "The Author s Licensee IntechOpen", "The Author reserves all moral rights over the deposited text", "The Author must follow a good scientific practice", "The Author hereby warrants that he she is the owner", "The American University in Cairo grants authors of theses", "The American Medical Association is accredited by the Accreditation Council", "The American Astronomical Society AAS established in", "The Altmetric Attention Score is a", "The Agency for Healthcare Research and Quality AHRQ through its", "The African Journal of Political Science is the flagship publication", "The Adaptive Dynamics Network at IIASA fosters the development", "The Activities of the International Conference is in line", "The Academic Network of European Disability experts ANED was established", "The ASU Library acknowledges the twenty two Native", "The ASU Library acknowledges the twenty three Native", "The ACSESS Digital Library will not be available after March", "That the author s agree to transfer to Baishideng", "Thanks to the University of", "Thanks to Allah for", "Thank you for visiting nature", "Thank you for purchasing", "Thank you for downloading this eBook", "Thank you for downloading the Public Knowledge", "Thank you for agreeing to be a reviewer", "Thank you for accessing the AHA Journals CME program", "Thank you for accepting the paper", "Tez yazım kurallarına uygun olarak hazırlanan bu tezin", "Tez icindeki tum verilerin akademik kurallar cercevesinde tarafımdan", "Textualities Literature and Print Culture is an online journal", "Tests measures are copyright protected", "Tesis doctoral inedita leida en la Universidad", "Tesis doctoral inedita Universidad", "Tesis doctoral de la Universidad", "Terræ Didatica e a publicacao periodica do Instituto", "Terms of Use Works in Treasures UT Dallas are made", "Terms of Use This item is made available", "Terms of Use This article was downloaded from", "Terms of Use This article is made available", "Terms of Use Full copyright to this work is retained", "Terms of Use Data Repository for the U", "Terms of Use Copyright Notice Please be aware that materials", "Terms and Conditions Terms and Conditions", "Term of patent years To all whom it may", "Teoria e Cultura e uma publicacao semestral", "Tento web obsahuje aplikace Google Adsense a Google analytics", "Tento clanek je publikovan v rezimu tzv otevreneho pristupu k vedeckym", "Teniendo en cuenta lo anterior se declara que el libro", "Telif hakları geregince yayın erisime kapalıdır Yayın yayıncı tarafından erisime", "Telah diperiksa dan divalidasi dengan baik dan sampai pernyataan ini", "Tekija ei ole antanut lupaa avoimeen julkaisuun", "Technical Reports Scientific and technical S&T reports conveying results of Defense sponsored research", "Taylor & Francis makes every effort to ensure the accuracy of all the information the Content contained in the publications on our platform", "Taylor & Francis does not sell reprints or permissions", "Tap tin PDF ma ban chon se đuoc tai ve", "Tampere University of Technology Master of Science Thesis", "Tambem tenho ciencia de que ha autorizacao para assumir contratos", "Tags freedom of speech investigative journalism journalism journalism", "Tags a2k access to knowledge authors authors rights", "Tags a2k access access authors benefits of open access citation", "Table B Group of educational components in the student's degree", "TRANSACTIONS OF THE ROYAL SOCIETY OF TROPICAL MEDICINE", "TRAC has now made this paper available as Open Access", "TO AMEND THE CODE OF LAWS OF SOUTH CAROLINA", "THIS CARNEGIE MELLON UNIVERSITY AND SOFTWARE ENGINEERING INSTITUTE MATERIAL IS", "THIS BOOK IS AN OPEN ACCESS E BOOK", "THE SOFTWARE IS PROVIDED AS IS", "THE MATERIAL POSTED ON THIS ROSE HULMAN REPOSITORY IS TO", "TAM technical reports include manuscripts intended for publication theses", "T4 A virulent lytic bacteriophage of E coli It has", "S⑾ t 筩 剓 _ 珛 浗 k 濜 n 竨 q 点 ye 閺 覧 燇 敏 w 煸 揠", "Słowa kluczowe Camino de Santiago", "Syukur alhamdulillah peneliti ucapkan kehadirat Allah", "Svojim podpisom potvrdzujem ze odovzdana elektronicka verzia prace je identicka", "Sus articulos se publican bajo la licencia", "Supplemental digital content is available", "Summary This resource is a citation record only", "Summary Este portal disponibiliza revistas cientificas publicadas", "Summary Das Buch findet sich in Munster in der Bibliothek", "Sumario Tesis doctoral inedita leida en la Universidad", "Sumario Estou ciente de que em sendo aprovado a publicacao", "Sumario Comunicacion presentada en el Congreso Internacional Interdisciplinar", "Suivre la vie du site fr Suivre la vie du", "Subscription Information Online subscribers are entitled", "Submitted paper will be firstly reviewed by the editors", "Submission of a manuscript implies that the submitted work", "Submission of a manuscript for publication implies the transfer", "Submeto o trabalho apresentado como texto original a Comissao Editorial", "Submeto emos o trabalho apresentado texto original", "Submeto emos o presente trabalho texto original e inedito de", "Su proposito es investigar sobre la relacion y evolucion de", "Su articulo es presentado de forma digital y formato PDF", "Studienarbeit aus dem Jahr im Fachbereich", "Students faculty staff and community users interested conducting research with", "Student percentages are computed as the percentage of total student", "Student authors retain copyright to this work", "Student author s retain copyright to this work", "Strona wykorzystuje pliki cookies", "Strengthening precast prestressed hollow core slabs to resist negative moments", "Strathprints makes available Open Access scholarly outputs", "Strathprints is designed to allow users to access", "Strategic Insights is a quarterly electronic journal", "Strategic Insights is a monthly electronic journal", "Strategic Insights is a bi monthly electronic journal", "Stoker collection of slides scanned by theAntarctic Legacy", "Statement on Harmful Material Materials in UNO Libraries Archives", "Statement of the Agreement This is an agreement under which all authors represented by the corresponding author", "Statement of the Agreement This is an agreement under which", "Staff and students login below", "Staff and students at the University of Worcester can access", "Sprachkunst is an Austrian periodical for literary studies", "Special Offer Download multiple Technical Papers", "Southern Cross University ePublications SCU is an electronic repository", "South Ayrshire Council use Google Analytics", "South African Journal of Libraries and Information Science", "Source of Description This bibliographic record is available", "Sorry we can t seem to find the page", "Some thesis volumes scanned as part of the", "Some theses deposited to NRL up to and including were digitised by the British Library", "Some of these resources may contain offensive stereotypes", "Some of these resources may contain offensive language", "Some of the resources may contain offensive language", "Some of the material in is restricted to members of", "Some of the images and language that appear in this these digital collection", "Some of the images and language that appear in the", "Some material related to John Muir may be protected by", "Some keywords semantic web linked data big data ontology modeling", "Some instruments administered as part of this study may contain", "Sollte hier kein Volltext PDF verlinkt", "Soil Science Society of America S Segoe Rd Madison", "Societe des Auxiliaires des Missions holds the proprietary right of", "Sobre a JusLaboris A JusLaboris", "Slavisticna revija publishes fully open access journals", "Skripsi saya ini adalah asli dan benar benar hasil karya", "Skripsi ini saya persembahkan untuk Kedua orang tuaku atas untaian", "Skripsi ini disusun sebagai persyaratan untuk mencapai derajat", "Skripsi ini diajukan untuk memenuhi sebagian persyaratan", "Sitientibus serie Ciencias Biologicas SCB is an open access journal", "Site Update Please note that EUP implemented the CONNECT login", "Sinon vous pouvez egalement telecharger directement", "Since its foundation in GRIN has specialized in publishing academic", "Since January the Journal of Astrophysics and Astronomy has moved", "Simply purchase your Lancet Choice pass", "Sie durfen die Dokumente picht fur offentliche Oder kommerzielle", "Si vous desirez avoir plus d information concernant", "Si vietne izmanto Manakin jaunu DSpace saskarni", "Si pubblica per gentile concessione del", "Si lo prefiere tambien puede descargar el archivo", "Si ho prefereix tambe pot descarregar l arxiu PDF", "Si desea consultar esta tesis por favor sirvase completar", "Shibboleth will soon be available on the Torrossa platform", "Shibboleth is now available on the Torrossa platform", "Settings Data policy Compare with web sources", "Set the date range to filter the displayed results", "Serwis uzywa ciasteczek cookies", "Serwis tworzony przez IAiE PAN IBL PAN", "Serwis PBN wykorzystuje pliki cookies ciasteczka", "Serwis Infona wykorzystuje pliki cookies ciasteczka", "Seminar paper from the year in the subject", "Seminar Nasional ini diharapkan sebagai forum diskusi hasil", "Seminar Nasional Pendidikan yang diselenggarakan oleh Universitas PGRI Palembang", "Seluruh bagian dari skripsi ini adalah karya saya sendiri selain", "Self archiving of the author version is not yet supported", "Select the SEEK icon to attempt to find the referenced", "Select the SEEK icon to attempt to find the reference", "Select All View abstracts of articles selected", "Segnalazioni con codici Alcuni dati obbligatori per", "See preceding abstract", "See more details", "See full text article at JSTOR", "Sedo Logo This webpage was generated by the domain owner", "Section of the Repository policy for OpenAIR RGU available", "Second pilier de l ecole le Centre de Recherche a", "Search or browse through our database", "Se solicita a los autores que diligencien el documento de", "Se reserva esta propiedad intelectual y la informacion de los", "Se recomienda a los autores que compartan sus articulos", "Se recomienda a los autores as difundir su obra", "Se pueden copiar usar difundir transmitir y exponer", "Se puede compartir el material de la revista en cualquier", "Se puede compartir copiar y redistribuir el material en cualquier", "Se permite y se anima a los autores a difundir", "Se permite y recomienda a los autores as difundir", "Se permite y recomienda a los autores as difundir", "Se permite y recomienda a los autores as a publicar", "Se permite y anima a los autores a difundir", "Se permite la reproduccion del contenido para actividades no comerciales", "Se permite la reproduccion de los articulos siempre y cuando", "Se permite a los autores difundir electronicamente", "Se informa que la fecha limite de recepcion de textos para el dosier", "Se informa a los autores que una vez que sus", "Se considera plagio como la copia textual o parcial", "Se autoriza cualquier reproduccion parcial o total de los contenidos", "Scope of Coverage As its first phase of development the", "ScienceDirect is phasing out support for older versions of Internet Explorer", "ScienceDirect features may not work properly in your current browser", "Science the State and the City Britain s Struggle", "Science News has been published since", "Science & Technology Life Sciences & Biomedicine Biochemistry & Molecular", "School Copyright ©", "Scholars may without prior permission quote from the Journal", "ScholarWorks is a shared institutional repository", "Saya yang bertanda tangan di bawah ini Nama", "Saya menyatakan dengan sesungguhnya bahwa", "Saya menyatakan dengan sebenar benarnya", "Saya menyatakan bahwa seluruh bagian karya ini adalah karya sendiri", "Saya menyatakan bahwa dalam skripsi ini tidak terdapat karya yang", "Saya menyatakan bahwa dalam karya seni dan pertanggungjawaban tertulis ini", "Saya menyatakan bahwa Skripsi dan tugas pribadi yang saya serahkan", "Saya juga tidak keberatan bahwa pihak editor akan mengubah memodifikasi", "Saya dengan ini menyatakan bahwa di dalam skripsi ini tidak", "Saving the PDF outside of ReadCube", "Sauf mention contraire ci dessus le contenu de cette", "Saudi Medical Journal is copyright", "Sanksi pelanggaran pasal Undang undang Hak Cipta UU No Tahun", "Sanksi pelanggaran Undang undang Hak Cipta UU No Tahun Barang", "Salvo indicacion contraria todos los contenidos", "Sackungszone f Ein Rutschungsmerkmal Definition lt MLG Krauter Bereich der", "Sa A1 Grades of cleanliness of", "SUTIR collects the academic work of the faculty students and staff", "SUMMARY OF THESIS This thesis is available at the Library", "SUCRA は 埼玉 大学 の 機関 リポジトリ で ある と共に", "SUBMISSION GUIDELINES AUTHOR GUIDELINES", "STUDENT AGREEMENT I represent that my thesis or dissertation and abstract are my original work", "STUDENT AGREEMENT I represent that my thesis or dissertation", "SEO Economisch Onderzoek doet onafhankelijk toegepast onderzoek in", "Rights ผล งาน นี้ เผยแพร่ ภาย ใต้", "Rights ©", "Rights unrestricted I hereby certify that", "Rights to this material belong to Rice University", "Rights assessment remains the responsibility of the researcher No known", "Rights and Permissions The author retains", "Rights Use of this product is restricted", "Rights This work is under a CC BY license", "Rights This work is made available for non commercial", "Rights This volume was digitized", "Rights This resource may be copyright protected", "Rights This resource is protected by copyright", "Rights This publication is copyrighted", "Rights This object may be copyright protected", "Rights This object has been provided to the NJDH after", "Rights This object has been provided by Rutgers University Libraries", "Rights This material may be used for scholarly purposes", "Rights This material is provided for private study", "Rights This material is presented to ensure timely dissemination", "Rights This item may be protected by copyright", "Rights This item is authored by federal employees", "Rights This Item is protected by copyright", "Rights The publisher does not allow this work", "Rights The material on this site may be covered under", "Rights The copyright law of the United States Title US", "Rights The copyright and related rights status of this Item", "Rights The Chris McGregor Jazz Collection having been signed over", "Rights Statement You are free to use this Item in", "Rights Statement This item is protected by copyright and or", "Rights Rutgers University owns the copyright in this work You", "Rights Property rights and copyright reside with the Regents of", "Rights MAE provides access to these materials for educational", "Rights L acces als articles a text complet inclosos a", "Rights Items indicated with a © are under copyright and", "Rights Institute of Oriental Research Russian Academy of Sciences has", "Rights Information This document is a product of the Illinois", "Rights Images provided for research and reference use only", "Rights Holder This image is made available by the University", "Rights Except where otherwise noted this content is released under", "Rights Except where otherwise noted the contents of this presentation", "Rights Except where otherwise noted by inclusion of a source", "Rights El la autor a cede de forma no exclusiva", "Rights Digital copies of photographs are provided", "Rights Copyright© is held by the author", "Rights Copyright of images owned by", "Rights Copyright is held by the author", "Rights Copyright for scholarly resources published in RUcore is retained", "Rights Copyright Hilandar Research Library", "Rights Copyright American Institute of Physics", "Rights Copia permesa amb finalitat d estudi", "Rights By exercising the Licensed Rights You accept and agree to be bound by the terms", "Rights By exercising the Licensed Rights You accept and agree", "Rights By downloading this document or using any information contained", "Rights By downloading and or using", "Rights All UHM dissertations and theses are protected by copyright", "Richard Skinner has been working with the department of Environmental", "Revue electronique publiee par la Maison des Sciences", "Revista licenciada pela Creative Commons", "Revista electronica da Sociedade Cientifica da Universidade", "Revista electronica cuatrimestral sobre cuestiones rurales en sus aspectos sociales", "Revista de Filosofia es una publicacion semestral", "Revista cientifica de periodicidade semestral", "Revista cientifica anual que publica articulos originales", "Revista Pueblos y fronteras digital es una publicacion editada", "Revista Ciencia Agronomica", "Revista Acta Universitaria Volumen", "Reviews available at https www cambridge org core terms", "Reuse Copies of full items can be used for", "Returned to author for revisions", "Resumen temporalmente no disponible La presente obra no cuenta con resumen", "Resumen Universidad Nacional Agraria", "Resumen Tese doutorado Universidade Federal de Santa Catarina", "Resumen Tese doutorado Universidade", "Resumen Signo y Sena es la revista especializada", "Resumen Proyecto de exportacion presentado a la Facultad", "Resumen Nota este documento forma parte de la coleccion", "Resumen Dissertacao mestrado Universidade", "Resumen De acuerdo a la normativa de TFEs el repositorio no puede dar acceso", "Resume Abstract Copyright c Elsevier", "Restricted to current Rensselaer faculty", "Restricted access to the most recent articles", "Restricted Item Print thesis available", "Resource Relation Conference Mendeleev congress", "Resource Relation Conference International conference", "Residents of European Union countries need to add a Book Value Added Tax", "Reservats tots els drets", "Reservados todos los derechos por la legislacion", "Reservados todos los derechos conforme a la ley", "Reservados todos los derechos Ni la totalidad ni parte de", "Reservados todos los derechos El contenido de esta obra esta", "Reservados todos los derechos De acuerdo con la legislacion vigente", "Researchers can access this thesis", "Research at York St John RaY is an institutional repository", "Research Repository UCD is a digital collection", "Requests for single copies of a Paper will be filled", "Reproduction printing downloading or copying of digital files", "Reproduction of this publication for educational or other", "Reproducing this article including by photocopying is only authorized", "Reproduced with permission of the copyright owner", "Reproduced by permission of one of the founding editors", "Repository of the Academy's Library", "Repository STKIP PGRI NGANJUK is powered by EPrints", "Reporting Burden We try to create forms and instructions that are accurate", "Reliable analytical measurements are mandatory for legal compliance with government", "Register your specific details and specific drugs of interest", "Register with NCJRS to receive NCJRS's biweekly e newsletter", "Regency Drive Pleasanton CA USA Telephone", "Recurso de aprendizaje de la Universitat", "Recurs d aprenentatge de la Universitat", "Records in MARC format for publications in the DiVA", "Recordings and texts for home and academic use", "Recordamos a nuestros colaboradores y autores interesados as que", "Reconocimiento NoComercial CompartirIgual by", "Reconocimiento Debe reconocer los creditos de la obra", "Reconocimiento Debe reconocer adecuadamente la autoria", "Reconocer los creditos de la obra de la manera especificada", "Recipient s will receive an email with a link", "Received in revised form", "Readers may download papers", "Readers are reminded that this work is protected", "Readers are reminded that copyright subsists in this extract", "Readers are free to copy display and distribute this article", "Read these instructions Keep these instructions Heed", "Read article for free from open access", "ReUSG Unless indicated otherwise fulltext items are protected by copyright", "ReCIBE es semestral y publica articulos ineditos arbitrados en ingles", "Re visiones nace de la necesidad de volver una y", "Rapid responses are electronic letters to the editor", "Rangelands a bi monthly publication of", "Raktazodziu debesis Europos Sajunga Europos Sajungos teise", "Radovi objavlјeni u casopisu nalaze se u otvorenom pristupu", "RU Economicas esta basado en EPrints el cual es", "RSACP wishes to inform that it shall be discontinuing", "RMIT University has undertaken diligent search endeavours", "RMIT University acknowledges the people of the Woi wurrung", "RILEM has been organising symposia and workshops since its foundation", "RIGHTS This article is licensed under", "REdutic es el repositorio para el apoyo a los docent", "RESUMO Dissertacao de Mestrado", "RESUMEN DE LA MEMORIA PARA OPTAR AL TITULO DE INGENIERO", "REPOSITORY STIKES dr Soebandi is powered by EPrints", "REPOSITORY STIK SINT CAROLUS is powered by EPrints", "RECUERDE Al pulsar el enlace Texto completo usted abandonara el entorno MEDES", "RCNi is a wholly owned subsidiary of", "RACIMO Repositorio Institucional esta basado en EPrints", "Qui sotto puoi vedere in anteprima la prima pagina di questo articolo", "Questo sito utilizza solo cookie tecnici propri e di terze parti", "Questo sito utilizza solo cookie tecnici e di analytics propri", "Questo sito utilizza i cookie", "Questo sito utilizza dei cookie", "Questo sito utilizza cookies", "Questo sito e stato realizzato dalla", "Questa rivista e stata iscritta al Tribunale di Milano", "Quedan rigurosamente prohibidas sin la autorizacion escrita de los titulares", "Queda prohibida la reproduccio transformacio distribucio i comunicacio publica d", "Que l obra es una obra original i que no", "Que el autor declara que sus derechos sobre el manuscrito", "Que el Congreso Constituyente reunido en esta ciudad el 1o", "Qualsevol forma de reproduccio distribucio comunicacio publica o transformacio", "Purpose led Publishing is a coalition", "Purpose Scope Scholars Bank is the open access repository", "Puji syukur kami panjatkan kehadirat Allah SWT", "Pueden copiarlos distribuirlos comunicarlos publicamente", "Publishing your paper with", "Publishers Note Every effort has been made to comply with", "Publisher's Disclaimer This is a PDF file", "Publisher's Disclaimer E publishing ahead of print is increasingly important", "Publisher version archived with the permission of", "Publisher site this onShowPLink", "Published papers become the copyright of", "Published by the College of", "Published by the BMJ Publishing Group Limited", "Published by the American Physical Society", "Published by the American Mathematical Society", "Published by McGraw Hill", "Published as a separate and in The Journal of Psychology", "Published September Facts and recommendations in this publication may no", "Published October Facts and recommendations in this publication may no", "Published November Facts and recommendations in this publication may no", "Published May Facts and recommendations in this publication may no", "Published March Facts and recommendations in this publication may no", "Published June Facts and recommendations in this publication may no", "Published July Facts and recommendations in this publication may no", "Published January Facts and recommendations in this publication may no", "Published February Facts and recommendations in this publication may no", "Published Facts and recommendations in this publication may no longer", "Published December Facts and recommendations in this publication may no", "Published August Facts and recommendations in this publication may no", "Published April Facts and recommendations in this publication may no", "Publikacja jest chroniona prawem autorskim", "Publicly available via the Travelers in the Middle East Archive", "Publications of the World Health Organization enjoy copyright", "Publication of articles in EHP does not mean", "Publication information Journal de mathematiques", "Publicacao oficial da Faculdade de Ciencias Humanas da Universidade", "Public reporting burden for this collection of information is estimated", "Public reporting burden for the collection of information is estimated", "Public Domain We believe this material to be", "PubliCatt e il repository istituzionale", "PsychiatryOnline subscription options offer access", "Provides a schedule of conference events", "Provided they are the owners of the copyright", "Provenance This electronic version is made publicly available", "Provenance Selected correspondence has been digitised", "Provenance Copyright material removed from digital thesis", "Prospective authors are requested to submit new unpublished manuscripts", "Projektet Det svenska ordforradets utveckling finansieras med", "Projekt Repozytorium otwartego dostepu", "Project MUSE promotes the creation and dissemination of essential humanities", "Project HOPE is a global health and humanitarian relief organization that places power in the hands of local health care workers to save lives across the globe", "Prohlasuji ze jsem diplomovou praci vypracovala", "Prohlasuji ze jsem byl seznamen s tim ze", "Programar una capacitacion Prestamo externo Prestamo interbibliotecario", "Privacy statement The names and email addresses entered in this journal site", "Priority date The priority date is an assumption and is not a legal conclusion", "Prior to September COUNTER data was not collected and", "Printed with permission of the", "Printed from Oxford Classical Dictionary", "Print Online Subscrbers of a journal", "Prices in US apply to orders placed", "Prevention Obtain special instructions before use Do not handle until all", "Preserving reproducing and distributing thesis research is an important part", "Presents the introductory welcome message from the conference", "Presents an index of the authors whose articles are published", "Presents a listing of the editorial board board of governors current staff", "Presentations without an abstract printed in the proceedings do not", "Preprints sao relatos preliminares", "Preprints are preliminary research reports", "Prava Authors who publish with this journal agree", "Prava A university thesis is a work protected by the Copyright", "Pour un usage strictement prive la simple reproduction du contenu", "Pour respecter les droits d auteur la version electronique", "Pour connaitre les regles de depot sur une archive ouverte", "Pour acheter un ou plusieurs articles vous devez dans un premier", "Poster i MARC format for publikationer i DiVA", "Portions of this issue may be available for CME credit", "Por tratarse de una publicacion regida por los Creative Commons", "Por tanto declaro lo siguiente He mencionado todas las fuentes", "Por que importa el acceso abierto", "Por medio del presente escrito autorizo autorizamos a la", "Por medio del presente escrito autorizo Autorizamos a la Universidad", "Por medio del presente escrito El los firmante s autores", "Por medio del presente documento los autores o titulares del", "Por medio del presente documento certifico que he leido la", "Por medio de la presente hago constar que soy autor", "Por medio de la presente en mi nuestra condicion de", "Por medio de la presente en mi condicion de autor", "Por medio de este formato manifiesto mi voluntad de autorizar", "Por medio de esta licencia se manifiesta que no se", "Por medio de esta comunicacion certifico que el articulo que", "Por lo anterior confirmo que el trabajo entregado puede ser", "Por la presente declaro que soy autor del articulo titulado", "Politicas del Portal Los contenidos que se encuentran en Infomed estan", "Politicas de seccion Proceso de evaluacion por pares Politica de acceso", "Politicas de la Revista Los contenidos que se encuentran en", "Politica de evaluacion por pares Politica de acceso abierto", "Pola dodatkowe Licencja Utwor jest udostepniany na licencji", "Pola dodatkowe Licencja Korzystanie z tego materiału jest mozliwe zgodnie", "Podpisani izjavljam da je diplomsko delo rezultat lastnega raziskovalnega dela", "Podpisana izjavljam da je naloga rezultat lastnega raziskovalnega dela Izjavljam", "Podpisana izjavljam da je magistrsko delo rezultat lastnega raziskovalnega dela", "Podpisana izjavljam da je diplomsko delo rezultat lastnega raziskovalnega dela", "Plus ICANN fee of cents per domain name year", "Please select whether you prefer to view the MDPI pages", "Please see Document Availability for additional information on obtaining", "Please read the legal small print and other information", "Please read instructions carefully before completing this form The instructions", "Please note this is a temporary local copy", "Please note that your registration", "Please note that where the full text provided on King's", "Please note that this student dissertation is made available", "Please note that this material is for use ONLY", "Please note that the full text version provided", "Please note that the Recommended Citation provides general citation information", "Please note that the Publication Information provides general citation information", "Please note that streaming video is not currently available", "Please note that print copies of theses may be available", "Please note that as of the 1st of August", "Please note that all articles available in epub", "Please note some of this content was published prior to", "Please note neither this list nor its contents are final", "Please note UC Press e books must be purchased separately", "Please note This MarESA report is a dated version", "Please note SAS space will not be accepting new items", "Please note Recorded presentations are still being processed and added", "Please note Linked content is NOT stored on Open Access", "Please login to MyJ GLOBAL", "Please help populate SUNScholar", "Please enter information about why you require access to this item", "Please cite this article as", "Please be aware that the text in the supplied thesis", "Please be aware that the Research Repository and Research Data", "Please be advised that due to scheduled maintenance work", "Pisci besedila potrjujejo da so avtorji", "Physics Essays has been established as an international journal", "Photographs included in the original manuscript have been reproduced", "Philippine Studies is published by the Ateneo de Manila University", "Phat huy truyen thong nam tu hao cua truong", "Pharmacological Reviews presents important review articles", "Personal subscribers to Nature can view articles", "Perpustakaan pusat ITATS pada tahun akademik tercatat memiliki koleksi", "Perpustakaan FIB Unilak menjadi salah satu referensi dalam", "Pernyataan ini saya buat dengan sesungguhnya dan apabila", "Pernyataan gagasan maupun kutipan baik langsung maupun tidak langsung yang", "Permissions may be sought directly from Elsevier", "Permissions This work is protected by copyright", "Permissions This work is licensed under", "Permission to use or to order reproductions must be obtained", "Permission to use copy", "Permission to reproduce a portion", "Permission to reprint or translate and reprint from Michigan Dairy", "Permission to make digital or hard copies", "Permission is hereby granted to the individual purchaser", "Permission is hereby granted free of charge", "Permission is granted to make and distribute verbatim copies", "Permission is granted to copy", "Permission is granted by the Natural Resources Institute", "Permission is granted by Sports Business Journal for SUrface to", "Permission has been granted to the Library of", "Permission has been granted by Library Journal to supply this", "Permisos que vayan mas alla de lo cubierto por esta", "Perfiles Latinoamericanos es una publicacion semestral electronica de acceso libre", "Per consultare la versione cartacea", "People rush to a site hit by what activists said", "Penyunting menerima sumbangan tulisan yang belum pernah diterbitkan dalam media", "Penyunting menerima artikel yang belum pernah diterbitkan dalam media lain", "Penulis sangat menyadari bahwa skripsi ini masih banyak kekurangan oleh", "Penulis menyadari sepenuhnya bahwa laporan Tugas Akhir ini masih jauh", "Penulis menyadari sepenuhnya bahwa apa yang tersaji dalam skripsi ini", "Penerbit menerima tulisan yang terkait ilmu perpustakaan informasi dan kearsipan", "Penelitian telah selesai dilaksanakan pada perusahaan", "Pendiente autorizacion de autor Texto completo consultar en Sala Tesis", "Pay per view article purchase PPV", "Pay for Admission You may access all content", "Patvirtinu kad baigiamasis darbas paremtas mano pacios", "Patvirtinu kad baigiamajame darbe nera naudojamasi kitu darbais", "Past Imperfect is a peer reviewed graduate student journal", "Password Reset We have updated our systems", "Passages attributed to Wikipedia the free encyclopedia are exempt", "Pascal Exact sciences and technology", "Pascal Biological and medical sciences", "Pascal 002 Biological and medical sciences", "Pascal 001 Exact sciences and technology", "Partners of the project entitled Pilot national open access", "Part or all of this report is presented in Portable Document Format", "Paraules clau COVID Constitucio", "Para leer y descargar este y todos los documentos del Centro de Documentacion", "Para indexacao dos documentos e utilizado o Thesaurus do INIS", "Par le telechargement d un document ci apres l œuvre", "Papers presented to the 20th International Conference", "Papers presented to the 19th International Conference", "Papers presented to the 18th International Conference", "Papers presented to the 17th International Conference", "Papers presented to the 16th International Conference", "Papers presented to the 15th International Conference", "Papers presented to the 14th International Conference", "Papers presented to the 13th International Conference", "Papers presented to the 12th International Conference", "Papers presented to the 11th International Conference", "Papers presented at the Eleventh International Conference", "Papers are issued by The Rand Corporation", "Paper yang akan dipublikasikan di dalam berkala", "Paper presented to the 3rd", "Paper presented to the 10th International Conference", "Paper presented at the XXXIII", "Paper presented at the South African Transport Conference", "Paper presented at the South African Society", "Paper presented at the 9th International Conference", "Paper presented at the 9th Annual", "Paper presented at the 8th International Conference", "Paper presented at the 8th Annual", "Paper presented at the 7th International Conference", "Paper presented at the 7th Annual", "Paper presented at the 6th International Conference", "Paper presented at the 6th Annual", "Paper presented at the 5th International Conference", "Paper presented at the 5th Annual", "Paper presented at the 4th International Conference", "Paper presented at the 4th Annual", "Paper presented at the 3rd International Conference", "Paper presented at the 3rd Annual", "Paper presented at the 33rd Annual", "Paper presented at the 32nd Annual", "Paper presented at the 31st Annual", "Paper presented at the 30th Annual", "Paper presented at the 2nd International Conference", "Paper presented at the 2nd Annual", "Paper presented at the 29th Annual", "Paper presented at the 28th Annual", "Paper presented at the 27th Annual", "Paper presented at the 25th Annual", "Paper presented at the 24th Annual", "Paper presented at the 22nd Annual", "Paper presented at the 21st Annual", "Paper presented at the 20th Annual", "Paper presented at the 1st International Conference", "Paper presented at the 1st Annual", "Paper presented at the 19th Annual", "Paper presented at the 18th Annual", "Paper presented at the 17th Annual", "Paper presented at the 16th Annual", "Paper presented at the 15th Annual", "Paper presented at the 14th Annual", "Paper presented at the 13th Annual", "Paper presented at the 12th Annual", "Paper presented at the 11th Annual", "Paper presented at the 10th Annual", "Paper Numbering Proceedings of SPIE", "Paleontologia Mexicana Vol num", "Palabras clave Colombia Leishmania", "Palabras clave Biblioteca PyM", "Palabras Clave Dante historia de la astronomia Fisica", "Padua Research Archive l archivio istituzionale", "Pacific Affairs is a peer reviewed", "Pa n SI abbreviation for Pascal PA n Abbreviation", "PURPOSE This information is collected by employers to comply", "PURPOSE Intellectual Property The IETF takes no position", "PRINTED FROM the OXFORD", "PRINTED FROM the Encyclopedia", "PRINTED FROM OXFORD SCHOLARSHIP ONLINE", "PRINTED FROM OXFORD HANDBOOKS ONLINE", "PREFACE THE Author of this very practical treatise on Scotch Loch Fishing", "PPV on Wiley Online Library will be unavailable", "PLEASE NOTE This work is protected by copyright", "PLEASE NOTE Boston University Libraries did not receive an Authorization", "PDFs are designed to be printed out and read", "PDF ウナラ obj FontDescriptor R DW endobj obj endobj", "PDF 閲覧 時に 認証 を 求め られる 記事 が ござい ます", "PDF 粤マモ obj endobj", "PDF 忏 嫌 obj endobj xref", "PDF 啀 姜 obj endobj obj Subtype CIDFontType0C Filter FlateDecode", "PDF to Text Batch Convert Multiple Files Software Please purchase", "PDF obj endobj", "PDF derivative scanned at ppi B&W", "PDF copies of all papers are available", "PDF aaIO obj endobj obj endobj obj endobj", "PDF Portable Document Format is a standard format for the distribution", "OΓA n4ƒ u Φ t_ δ ª A v", "Oversize materials eg maps drawings charts are reproduced by sectioning", "Oversize materials eg maps drawings and charts are photographed by sectioning the original", "Our website uses cookies", "Our website is evolving and our goal is", "Our thanks to those who have helped with this issue", "Our thanks to all those who have", "Our site uses cookies to", "Our requirements are stated in our rapid response terms and", "Our on line publications are scanned and captured using Adobe", "Our most academic publication offers research and surveys on monetary", "Our mission is to build healthier lives free of cardiovascular diseases and stroke", "Our link check indicates that this URL is bad", "Our intent is to provide an interdisciplinary forum for original", "Our free hosting service is supercharged", "Our conferences provide the opportunity to hear the latest research", "Our checks indicate that this address may not be valid", "Os trabalhos disponibilizados neste website podem ser consultados e reproduzidos", "Os textos publicados sao de propriedade da Viver IFRS", "Os textos contidos neste volume sao de responsabilidade exclusiva", "Os textos apresentados terao de ser originais", "Os manuscritos aceitos e publicados sao de propriedade", "Os leitores sao livres para transferir", "Os filtros disponiveis em Navegar", "Os direitos autorais dos artigos publicados", "Os direitos autorais de trabalhos publicados sao dos autores", "Os autores tem autorizacao para assumir contratos adicionais", "Os autores sao responsaveis em qualquer que seja o formato", "Os autores que publicam nessa revista devem concordar", "Os autores que publicam na Griot Revista de Filosofia mantem", "Os autores nao serao remunerados pela publicacao", "Os autores mantem os direitos e cedem", "Os autores mantem os direitos autorais", "Os autores e autoras mantem os direitos autorais", "Os autores dos textos publicados na Lusitania Sacra", "Os autores do artigo cedem a titulo gratuito", "Os autores devem ceder expressamente os direitos autorais a Universidade", "Os autores detem os direitos autorais ao licenciar sua producao", "Os autores ceden o dereito da primeira publicacion a revista", "Os autores cedem a Em Tese os direitos", "Os as autores as mantem os direitos autorais", "Os artigos submetidos a revista Formacao Online estao licenciados", "Os artigos publicados sao de total e exclusiva responsabilidade", "Os artigos publicados pela revista sao de uso gratuito destinados", "Os Direitos Autorais para artigos publicados", "Os A utores podem assumir contratos adicionais separadamente", "Orice parte din acest ghid poate fi copiata reprodusa", "Orbis scholae is an academic journal published by", "Optica is performing scheduled maintenance", "Optica Publishing Group developed the Optics and Photonics Topics", "Opis Zdigitalizowano i udostepniono w ramach projektu pn Rozbudowa otwartych", "Opinions expressed or implied in this website are solely", "Opinions expressed in AJIS are those of the authors", "Opinions conclusions and recommendations expressed", "Operacijo delno financira Evropska unija iz Evropskega sklada za regionalni", "OpenReview is a long term project to advance science through improved peer", "Open Research is a publicly accessible curated repository for the", "Open Collections is an initiative", "Open Access is the free immediate online availability", "Open Access This book is licensed", "Open Access This article is licensed under a Creative Commons Attribution", "Open Access This article is distributed under", "Online ordering is currently unavailable", "One author must agree to the Consent for Publication", "On the evening of the 4th December the Political Studies", "On the View Item page you will see the item record", "On the 26th of January the Master of the Rolls", "On selecting a constituent part of MU the Overview of publishing activities", "On desktop computers and some mobile devices you may need", "On behalf of the First International Conference on Law Business", "On Wednesday July GMT we ll be carrying out some essential maintenance", "On Monday November between GMT", "On Monday July GMT we ll be making some site updates", "On June IDEALS will undergo migration to", "Om du har skrivit in adressen for hand kan det", "Ohne Zusammenfassung", "Ohio State University Extension embraces human diversity and is committed", "Off campus WSU users To download", "Off campus UNL users To download", "Off campus South Dakota State University users To download", "Oferece acesso livre e imediato ao seu conteudo seguindo", "Oc e van der Grinten NV is the parent of", "Obtiznost zadaneho ukolu Splneni vsech bodu zadani Prace", "Obtain special instructions before use", "Obtain CPD Credit CUAJ now provides readers", "Objeto El AUTOR CEDENTE transfiere de manera TOTAL Y SIN", "Objectif Revue a comite de lecture du Comite d histoire", "OSTI GOV is the primary search tool for DOE", "OSA is able to provide readers links to", "OSA Publishing developed the Optics and Photonics Topics to help", "ORCID provides a persistent digital identifier that distinguishes you", "ORBI Detailled Reference", "ORBI Detailed Reference", "OPUS Open Publications of UTS Scholars is", "OPEN ACCESS This article is an open access", "OPEN ACCESS Articles published by this Open Access", "OJS is not currently accepting submissions or allowing existing users", "OCR Oxford Cambridge and RSA is a leading UK awarding", "OCL Oilseeds and fats Crops and Lipids est un journal a comite", "O si ho preferiu tambe podeu descarregar vos l arxiu PDF", "O s autor es do artigo aceito para publicacao", "O o autor a vem por meio desta declarar que", "O gerenciamento do Repositorio esta a cargo da Biblioteca do IPEN", "O conteudo de cada artigo resenha e ou traducao e", "O conteudo das obras e de responsabilidade exclusiva", "O autor transfere todos os direitos autorais do artigo", "O acervo da Forca Expedicionaria Brasileira e um um acervo", "O Termo de Uso pode ser modificado pela CAPES", "O Repositorio Institucional da Universidade Federal Rural da Amazonia RIUFRA", "O Reitor da Universidade Federal de Goias usando de suas", "O Portal eduCAPES e oferecido ao usuario condicionado a aceitacao dos termos", "O Portal de Periodicos Eletronicos da Universidade Federal de Pernambuco", "O PanistOpenUrl", "O O O O O", "O Hospital das Clinicas da Faculdade de Medicina da Universidade", "O Autor informa que o trabalho e de sua autoria", "Numero de Reserva de Derecho al uso exclusivo", "Now in its second century the Ithaca College School", "Now in its second centur the Ithaca College School", "Novitates Latin for new acquaintances published continuously", "Nous utilisons des cookies pour", "Nous offrons la possibilite d envoyer directement cet article en DPF a votre Kindle", "Nous apportons un soin particulier a la qualite des textes", "Notre plateforme utilise des cookies", "Notice Wiley Online Library will be unavailable", "Notice Some Wiley Online Library Journal subscribe and renew pages", "Notice Please note that this document may not", "Notice Please be advised that we experienced an unexpected issue", "Notice Pay per view article purchase", "Notes Licencja Korzystanie z tego materiału jest mozliwe", "Note you can select to send to either the", "Note to users If you re seeing this message it means that your browser", "Note generale La pagination indiquee dans la zone Pages", "Note You do not have sufficient privileges to preview", "Note This Work has been made available by the authority of the copyright owner", "Note The article usage is presented with", "Note OCR errors may be found in this Reference List", "Note Every effort has been made to comply with the", "Note Authors are encouraged to post copies", "Note After leaving this page to view an article", "Notas de reproduccion original Edicion digital a partir", "Notas Divulgacao dos SUMARIOS das obras recentemente incorporadas ao acervo", "Nota's van het Instituut zijn in principe interne communicatiemiddelen dus", "Nota El contenido de esta ponencia es responsabilidad del autor", "Nota De acuerdo con la Ley de Derechos de Autor", "Not available The author may have various reasons for not", "Nossa missao e difundir a producao cientifica nas areas", "Nos representantes do povo brasileiro reunidos em Assembleia Nacional Constituinte", "Northumbria University has developed Northumbria Research Link", "North American Fungi publishes original peer reviewed articles on Fungi", "Norsk institutt for forskning om oppvekst velferd og aldring", "Normal false false false", "Non refereed articles on project reports case studies work", "No se permite la reproduccion total o parcial", "No portion of this publication may be reproduced copied or", "No podemos asegurar que el autor le proporcione acceso", "No part of this work may be reproduced", "No part of this publication may be reproduced", "No part of this journal may be reproduced", "No part of this book may be used or reproduced", "No part of this book may be reproduced", "No paragraph of this publication may be reproduced", "No esta permitida la reproduccion total o parcial de este", "No es permet la reproduccio total", "No de Reserva de derechos al uso exclusivo", "No copyright is claimed by", "No autorizada su publicacion a solicitud del autor", "No article summary included", "No abstract", "Niniejsza strona internetowa wykorzystuje pliki cookie", "Niets van deze uitgave mag worden verveelvoudigd", "Niets uit deze uitgave mag worden verveelvuldigd en of openbaar", "Niets uit deze uitgave mag worden verveelvoudigd en of openbaar", "New platform launch preparation Individual article rental and purchase is", "New NBER affiliates are appointed through a highly competitive process", "New COMs $ yr plus cents yr ICANN fee", "Neuigkeiten Wegen Wartungsarbeiten kann zwischen Uhr und Uhr", "Neither the whole nor any part of the information contained", "Need more information Need to find more information", "Naturvardsverket har i flera sammanhang bl a i Aktionsplan for", "Nature Publishing Group supports standard reference manager software such as", "Naturaleza y Tecnologia revista electronica de la Division de Ciencias", "Nao havera pagamento a titulo de direitos autorais", "Nachfall m Einsturzmaterial n Einsturzgut n z B bei einem", "Na qualidade de titular dos direitos de autor autorizo", "Na qualidade de responsavel pela submissao do documento autorizo", "Na Obzornik zdravstvene nege Ob zeleznici", "NOTICE When government or other drawings specifications", "NOTICE We d value your feedback on using", "NOTICE We are currently experiencing issues regarding", "NOTICE This opinion is subject to formal revision", "NOTICE This is the authors version of a work", "NOTICE This is the author s version of a work", "NOTICE The author has granted a nonexclusive license", "NOTES 1Stresses above those listed under Absolute Maximum Ratings may", "NOTE We only request your email address so that", "NOTE This item is not available outside the", "NOTE Text or symbols not renderable in plain ASCII are", "NOTE Restrictions are in place to limit access", "NOTE All contributors disclosures must be entered", "NOTE All authors disclosures must be entered", "NO WARRANTY THIS CARNEGIE MELLON UNIVERSITY AND SOFTWARE ENGINEERING INSTITUTE", "NJIT Academic Integrity Code All Students should be aware that", "NIU theses are protected by copyright", "NERC has developed NORA to enable users to access", "NDL 雑誌 分類 ZS9 科学 技術 医学 病 理学 微生物", "NDL 雑誌 分類 ZS51 科学 技術 薬学 ZS9 科学 技術", "NDL 雑誌 分類 ZS31 科学 技術 医学 精神 神経 科学", "NDL 雑誌 分類 ZP17 科学 技術 化学 化学 工業 高", "NDL 雑誌 分類 ZN33 科学 技術 電気 工学 電気 機械", "NDL 雑誌 分類 ZN31 科学 技術 電気 工学 電気 機械", "NDL 雑誌 分類 ZM35 科学 技術 物理 学 ZN36 科学", "NCEJ is peer reviewed", "NCBI GEO standards and services for microarray data", "NAVER 네이버 Google 구글 Kakao", "NASA Tech Briefs announce new technology derived from", "NAJ supports free online communication and exchange", "My NCBI Help My NCBI saves searches and results from", "Muestra un archivo XML con los metadatos del articulo basados", "Muchas gracias por ofrecernos sus textos para que sean publicados", "Moreana is an international and non confessional journal which was", "More papers in Working papers from Massachusetts Institute of Technology", "Monthly maintenance is scheduled for", "Minerva Access is the University's Institutional Repository", "Military vehicles carrying DF ballistic missiles travel past Tiananmen", "Middlesex University Research Repository makes the University", "Microsoft Word Student progress files doc Student progress", "Microfilmed or bound copies of doctoral dissertations submitted to", "Meraja journal is a peer review journal", "Mer information om kursen liksom kommunikation", "Menyatakan dengan sesungguhnya dan sebenar benarnya bahwa Skripsi", "Menyatakan dengan sesungguhnya bahwa Tugas Akhir ini merupakan hasil penelitian", "Menyatakan dengan sebenarnya bahwa tesis", "Menyatakan dengan sebenarnya bahwa skripsi", "Menyatakan dengan sebenarnya bahwa hasil tulisan laporan Skripsi", "Menyatakan dengan sebenarnya bahwa dengan penuh kesadaran tesis ini", "Menyatakan dengan sebenarnya bahwa Tugas Akhir ini merupakan karya saya", "Menyatakan dengan sebenarnya bahwa Karya Tulis Ilmiah yang saya", "Menyatakan dengan sebenar benarnya bahwa hasil", "Menyatakan bahwa tugas akhir skripsi ini adalah ASLI karya tulis", "Menyatakan bahwa skripsi tersebut adalah karya saya sendiri dan bukan", "Menyatakan bahwa skripsi ini adalah ASLI karya tulis saya Apabila", "Menyatakan bahwa seluruh komponen dan isi dalam", "Menyatakan bahwa proyek akhir ini adalah hasil karya saya sendiri", "Menyatakan bahwa dalam skripsi ini tidak", "Menyatakan bahwa Semua yang ditulis dalam naskah skripsi ini merupakan", "Mensile fondato nel e diffuso esclusivamente in abbonamento", "Menimbang Bahwa untuk menyelesaikan studi Program Sarjana Fakultas Ekonomi Universitas", "Membership in IAEE is open to anyone worldwide", "Meeting Information Symposium on", "Meeting Information Balloon Symposium", "Meeting Information 9th Symposium", "Meeting Information 8th Symposium", "Meeting Information 7th Symposium", "Meeting Information 6th Symposium", "Meeting Information 5th Symposium", "Meeting Information 4th Symposium", "Meeting Information 3rd Symposium", "Meeting Information 2nd Symposium", "Meeting Information 24th Symposium", "Meeting Information 23rd Symposium", "Meeting Information 22nd Symposium", "Meeting Information 21st Symposium", "Meeting Information 20th Symposium", "Meeting Information 1st Symposium", "Meeting Information 19th Symposium", "Meeting Information 18th Symposium", "Meeting Information 17th Symposium", "Meeting Information 16th Symposium", "Meeting Information 15th Symposium", "Meeting Information 14th Symposium", "Meeting Information 13th Symposium", "Meeting Information 12th Symposium", "Meeting Information 11th Symposium", "Meeting Information 10th Symposium", "Meent u dat de digitale beschikbaarstelling van bepaald materiaal", "Medievalismo es una revista especializada en temas de contenidos historico", "Medieval Academy of America member access", "May be available online via journal", "MathJax Logo Mathematical formulae have been encoded as MathML", "Materials in this collection are made available for personal non commercial", "Materials in this collection are made available for personal", "Materialet i denne publikasjonen er omfattet av andverkslovens", "Material is made available in this collection at the direction of authors", "Material in this database may be protected by copyright", "Master's Thesis from the year", "Master files tif scanned at ppi", "Master files scanned at ppi bit Color", "Master files scanned at ppi Grayscale", "Master files scanned at ppi Black and White", "Master files scanned at ppi B&W", "Master files scanned at dpi Grayscale", "Maruzen eBook Library トライアル 中 まで 回 分間 の 試読", "Many versions of the free Acrobat Reader do not allow", "Many of the earliest books particularly those dating back to the", "Many historical books were originally published in small fonts which can make them very difficult to read", "Mantem os direitos autorais e concedem a revista", "Majority of the articles reproduced here are digests produced through", "Maintenance will be performed from pm CT January", "Made in Mexico all rights reserved", "Macaroni Wheat hard wheat Triticum durum 2n 4x varieties", "MSU uses DSpace software", "MSU E prints Repository is powered by EPrints which is", "MIT theses may be protected by copyright", "MIT theses are protected by copyright", "MIA OaM JMI DEA FDC JCA and their logos are", "MELSpace content providers and partners accept no liability to any", "MELINTAS applies the Creative Commons Attribution", "MDPI uses a print on demand service", "M BREGER Delta Scuti stars Observational aspects MS Cunha Theory", "Lunds Universitet med nio fakulteter samt ett antal forskningscentra", "Los usuarios puedan consultar el contenido de este trabajo", "Los usuarios del RDI UTI podran consultar el contenido", "Los usuarios de este sistema pueden hacer login", "Los trabajos enviados deben ser ineditos", "Los textos se difundiran con la licencia", "Los textos publicados en esta revista estan sujetos", "Los textos publicados en esta revista estan si no", "Los suscritos Directores de la Division de Postgrados", "Los originales de la revista Acordes publicados en papel", "Los manuscritos postulados a la Revista", "Los las autores as conservaran sus derechos de autor", "Los items de RUC estan bajo la proteccion de la Ley de Propiedad Intelectual", "Los firmantes conservaran sus derechos de autoria", "Los editores no comparten necesariamente las opiniones vertidas", "Los derechos del trabajo pertenecen al autor", "Los derechos del articulo estan sujetos a la Licencia", "Los derechos de los trabajos academicos publicados", "Los derechos de las imagenes publicadas pertenecen", "Los derechos de edicion son de la Universidad", "Los derechos de autor seran de la Universidad del Rosario", "Los datos presentados en los resultados son reales no han", "Los datos personales incluidos en la presente publicacion son propiedad", "Los contenidos publicados en IDP estan bajo una licencia", "Los contenidos de los articulos son responsabilidad de los autores", "Los contenidos de los articulos seran responsabilidad de los autores", "Los contenidos de este portal pertenecen", "Los autores y autoras ceden sus derechos de propiedad intelectual", "Los autores y autoras ceden sus derechos de propiedad", "Los autores son responsables de todas las autorizaciones", "Los autores son los responsables de los textos", "Los autores retienen los derechos de copia copyright", "Los autores respetan los derechos de autor", "Los autores que publiquen en la revista Ontare deben diligenciar", "Los autores que publiquen en esta revista aceptan", "Los autores que publican en esta revista estan de acuerdo", "Los autores que publican en esta revista", "Los autores que deseen publicar en Ius Inkarri deben autorizar", "Los autores pueden realizar otros acuerdos contractuales", "Los autores pueden realizar otros acuerdos contraactuales independientes", "Los autores pueden llegar a acuerdos contractuales por separado", "Los autores pueden establecer por separado acuerdos", "Los autores pueden celebrar acuerdos contractuales adicionales", "Los autores o titulares del derecho de autor", "Los autores mantienen los derechos sobre", "Los autores mantienen los derechos de autor", "Los autores deben declarar que el articulo es un trabajo", "Los autores deben aceptar transferir a la Revista", "Los autores de los articulos publicados en RPA retienen", "Los autores de los artAculos publicados en RPA retienen", "Los autores de articulos aceptados en", "Los autores conservaran sus derechos de autor", "Los autores conservaran los derechos de autor", "Los autores conservan sus derechos de autor", "Los autores conservan los derechos sobre su obra garantizando", "Los autores conservan los derechos patrimoniales copyright", "Los autores conservan los derechos de autoria", "Los autores conservan los derechos de autor", "Los autores autorizan de forma exclusiva a la revista Actualidades", "Los autores as traductores conservan los derechos de autor", "Los autores as que publiquen en esta revista permite", "Los autores as que publiquen en esta revista ceden los", "Los autores as pueden realizar otros acuerdos contractuales independientes", "Los autores as pueden realizar otros acuerdos", "Los autores as podran adoptar otros acuerdos", "Los autores as mantienen los derechos de autor y autorizan", "Los autores as conservaran sus derechos", "Los autores as conservan los derechos", "Los autores afirman reconocer que la Revista de Fomento", "Los autores abajo firmantes declaran Que es un trabajo", "Los as autores as traductores as conservan los derechos", "Los as autores as deben adherir a la licencia Creative", "Los as autores as cuyas contribuciones sean aceptadas", "Los as autores as conservaran sus derechos de autor", "Los articulos son responsabilidad de sus autores", "Los articulos publicados se distribuyen bajo una", "Los articulos publicados se distribuyen bajo un", "Los articulos publicados por la Revista", "Los articulos publicados por TECNIA pueden ser compartidos", "Los articulos publicados en este numero pueden ser reproducidos", "Los abajo firmantes confirman que todos los datos incluidos en", "Los Alamos National Laboratory an affirmative action", "Los Alamos National Laboratory an aHirmative", "Lorem ipsum dolor sit amet consectetur adipiscing elit", "Lorem ipsum dolor sit amet consectetuer adipiscing elit", "Login via your institution", "Localizacion Utopia y praxis latinoamericana revista internacional", "Localizacion Revista de estudios e investigacion", "Localizacion Mitologias hoy Revista de pensamiento critica", "Localizacion E Spania Revue electronique d etudes", "Localizacion Ausart aldizkaria arte ikerkuntzarako aldizkaria", "Linkoping University Electronic Press", "Linking to a non federal Website does not constitute an endorsement", "Limit of Liability Disclaimer of Warranty", "Licenza d uso L articoloe messo a disposizione dell utente", "Licenses restrictions placed on the data or limitations of reuse", "License fDPPL Any party may pass on this Work by", "License Use of this product is restricted", "License This work has been published open access", "License This thesis is made available by", "License Permission is hereby granted to the University of", "License Open Access This article is distributed under the terms", "License Commons Creative https", "License Commons Creative http", "Licencja Korzystanie z tego materiału jest mozliwe zgodnie z własciwymi", "Licencia de uso Usted puede copiar", "Licencia de Creative Commons Las os autoras es conservan", "Licencia Creative Commons Obra de portada", "Licence Creative Commons This is an Open Access article", "Licenca A concessao da licenca deste item", "Library of Congress Cataloging in Publication Data Names", "Letters commenting on an Annals article will be", "Les œuvres figurant sur ce site peuvent etre consultees et reproduites", "Les statistiques affichees correspondent au cumul d une part des vues des resumes", "Les revistes de l IEC allotjades", "Les publications encodees constituent la bibliographie", "Les opinions exprimees dans cette publication ne refletent", "Les notices bibliographiques sont placees sous licence", "Les invitamos al lanzamiento del numero con el coloquio", "Les fichiers html doc", "Les documents deposes sont indexes par les moteurs de recherche", "Les cookies nous permettent de personnaliser", "Leituras Italianas e um projeto integrado organizado pela Universidade", "Leidschrift is een zelfstandig wetenschappelijk historisch tijdschrift", "Leidschrift is an independent academic journal", "Legalidad Titulo de registro de marca ante el", "Legal sources and commentaries working tools Speeches and", "Left of header image", "Learn more about these metrics Article Views are the", "Le ultime tesi pubblicate Le tesi piu vendute Le tesi", "Le portail HAL de l Isara a ouvert en", "Le novembre se tenait la journee Open Access", "Le fichier PDF que vous avez selectionne devrait s afficher", "Le dispositif d acces distant simplifie mis en place par Cairn", "Le dernier numero d Arabesque la revue de l ABES", "Le depot institutionnel offre a la communaute scientifique un acces", "Le GREYC est un laboratoire de recherche associe au CNRS", "Le Debat’s aim is to bring together through discussion the contributions of the humanities", "Le Debat's aim is to bring together through discussion", "Le Colloque a pour objectif de promouvoir la langue francaise", "Le CEMEF est un centre de recherche de MINES", "Las tesis de este repositorio se encuentran protegidas bajo licencias", "Las revistas se encuentran alojadas en la plataforma de Open", "Las revistas publicadas por la Universidad Catolica", "Las revistas del Centro de Estudios Politicos y Constitucionales pueden consultarse tanto por esta misma via electronica", "Las revistas alojadas en la plataforma de Open Journal Systems", "Las reformas adiciones y derogaciones a los articulos", "Las personas autoras que publiquen en esta revista", "Las opinones expresadas por los autores", "Las opiniones y hechos consignados en cada articulo", "Las opiniones expresadas por los las autores", "Las opiniones expresadas por los autores", "Las opiniones expresadas en los capitulos de esta publicacion son de exclusiva", "Las obras se publican en la edicion electronica de la revista", "Las obras que se publican en esta revista estan sujetas a los siguientes terminos", "Las obras que se publican a traves del Fondo Editorial", "Las obras publicadas en este sitio pueden ser consultadas", "Las ediciones impresa y electronica de esta Revista son editadas", "Laboratorio es una revista electronica semestral", "La web de esic edu utiliza cookies", "La version digital de este libro aun no esta disponible", "La valutazione delle attivita di ricerca che si svolgono nelle", "La titularidad de los derechos patrimoniales de esta obra", "La tesis no ha sido autoplagiada", "La tesi e conservata presso il Nuovo Archivio di Legnaro", "La simulazione si basa sui dati IRIS e sugli indicatori bibliometrici", "La serie Responsabilite & Environnement publie trimestriellement des dossiers thematiques", "La serie Realites Industrielles publie trimestriellement des dossiers thematiques sur", "La revista y los textos individuales que en esta", "La revista se distribuye bajo una Licencia", "La revista es el titular de los derechos de autor", "La revista es de acceso abierto", "La revista electronica Roca es una publicacion de caracter cientifico", "La revista brinda acceso libre y total", "La revista Estudios Politicos autoriza la copia de articulos", "La revista Dictamen Libre cuenta con una licencia", "La revista Derectum cuenta con una licencia", "La revista CES Enfermeria es una publicacion de la Universidad CES", "La revista Berceo editada por el Instituto de", "La revista Antiguedad y Cristianismo es una revista cientifica", "La revista Antiguedad y Cristianismo es una revista cienti", "La revista Advocatus cuenta con una licencia", "La responsabilidad por los articulos y las resenas bibliograficas", "La responsabilidad de los trabajos firmados es de sus autores", "La responsabilidad de los textos publicados en la revista", "La reproduction ou representation de cet article", "La reference d une publication comprend", "La publicacion del articulo implica la donacion de los derechos", "La publicacion de articulos o resenas en la revista", "La presente simulazione e stata realizzata sulla base delle specifiche raccolte", "La presente revista y su contenido son propiedad de la", "La mise en ligne est assujettie a une moderation la", "La loi du mars n autorisant aux termes", "La loi du mars interdit les copies ou", "La licencia utilizada es CC BY", "La licencia utilizada es Atribucion No Comercial", "La legge ottobre n riconosce la dislessia", "La informacion contenida en este registro es de entera responsabilidad", "La inclusion de este articulo en el repositorio", "La grabacion del lanzamiento del numero con el coloquio", "La fonctionnalite CV proposee dans HAL permet au minimum d afficher dans", "La edicion electronica de la Revista de Estudios Andaluces", "La division Culture et Savoir est leader francais dans la mise", "La digitalizacion de este articulo se enmarca dentro del proyecto", "La diffusion de cette these se fait dans le respect", "La diffusion de ce memoire se fait dans le respect", "La diffusion de ce memoire ou de cette these se", "La detrazione fiscale delle spese per interventi di ristrutturazione edilizia", "La descarga del documento esta restringida", "La coulee continue des aciers Un exemple de developpement technique ou", "La consultazione on line degli articoli della Rivista Medico e", "La collection HAL ISEM est destinee au depot", "La cesion de derechos no exclusivos implica tambien la autorizacion", "La autorizacion para reproducir total o parcialmente esta obra debe", "La acupuntura es una tecnica terapeutica que", "La aceptacion de un original por parte de la revista", "La aceptacion de un articulo para su publicacion en la", "La aceptacion de manuscritos por parte de la revista implicara", "La aceptacion de colaboraciones por parte de la revista implica", "La Url del item en esta etapa no es permanente", "La Universidad no se hace responsable por los conceptos emitidos", "La Universidad de los Andes es una institucion autonoma independiente", "La Universidad de Sevilla se reserva todos los derechos", "La Universidad de Navarra el Servicio de Publicaciones como su editorial", "La Universidad de La Sabana informa que el los autor", "La Universidad de Cordoba es una Institucion de educacion superior", "La Universidad de Antioquia adquiere los derechos de uso", "La Universidad Tecnica del Norte dentro del proyecto Repositorio Digital", "La Universidad Santo Tomas informa que el los autor", "La Revue de Sante de la Mediterranee Orientale est une", "La Revue Medicale de Liege est publiee sous", "La Revista digital Matematicas Educacion e Internet es una publicacion", "La Revista TRASCENDER CONTABILIDAD Y GESTION es una publicacion", "La Revista Olimpia es una publicacion cientifica", "La Revista Notas Historicas y Geograficas publica resultados", "La Revista Letral es una publicacion de acceso abierto", "La Revista Iberoamericana de Educacion Superior RIES es una publicacion", "La Revista INGENIUM proporciona acceso abierto", "La Revista Granmense de Desarrollo Local REDEL surge como continuadora", "La Revista Estado Abierto y su contenido se brindan bajo", "La Revista CS no se hace responsable de las ideas", "La Ley Organica de de mayo de", "La Facultad de Medicina de la UNAM es responsable del", "La Editorial Universitaria Cuba publica bajo licencia", "La Discriminacion de los jovenes refugiados hombres y mujeres colombianos", "La Biblioteca Digital es una herramienta que permite el acceso libre", "LSE has developed LSE Research Online so that users may", "LJMU has developed LJMU Research Online for users to access", "LISHIZHEN MEDICINE AND MATERIA MEDICA", "LINGUIST will be on holiday recess from Saturday December", "LETRAS JURIDICAS REVISTA ELECTRONICA DE DERECHO DEL", "LATEX ist ein Satzsystem das fur viele Arten von Schriftstucken", "L organisme francais de recherche agronomique et de cooperation internationale pour", "L editeur du site PERSEE le Ministere de la jeunesse", "L autore ha il diritto di stampare o diffondere copie", "L autor que publica un article a Sintagma", "L autor a cedeix en exclusiva tots els drets", "L auteur a autorise l Universite de", "L auteur a accorde une licence", "L arxiu PDF que ha seleccionat s ha de carregar aqui si el seu navegador", "L articoloe messo a disposizione dell utente in licenza", "L archive ouverte pluridisciplinaire HAL", "L archive ouverte TEL propose en acces libre", "L archive ouverte HAL UGA accueille la production scientifique", "L acces aux references bibliographiques au texte", "L acces aux livres numeriques et les emprunts de livres papiers", "L acces aux archives de la serie", "L acces aux archives de la revue", "L acces als continguts d aquest document queda condicionat", "L acces als articles a text complet inclosos a RACO", "L Universite de Lausanne attire expressement l attention des utilisateurs", "L Ouvert est le journal de la Regionale de", "L Bresler Ed International Handbook of Research in Arts Education", "L Anses met en œuvre une expertise scientifique independante et", "Konten SMERU dapat disalin atau disebarluaskan", "Keywords in inglese domain name", "Keywords communication communicative competence", "Keywords assessment communication", "Keywords antarctic antarctic peninsula", "Keywords alaealised alkoholivastane hoiak", "Keywords adult education attitude children", "Keywords Meloidogyne incognita biological control", "Keywords Kazan Medical Archiv", "Keywords ICT Internet Web blended", "Keywords Environmental education Higher education", "Keywords Earthquake GPS Historical seismology", "Keywords Coordinate polari Dante", "Keywords Combustion chamber Gas turbine", "Keywords Bekhterev neurology archive", "Keywords Behavioral Psychological symptoms", "Keywords Barrier island Coastal Photography", "Keywords Arizona Colorado Montana", "Keywords Archive of the 19th century", "Keywords Architectural education Architecture", "Keywords Apiaceae Asteraceae COVID", "Keywords 2nd Congress of", "Keywords 20th century philosophy", "Keep it legal Whatever your use remember that you are", "Kata Kunci Arang aktif Biodiesel arang arang aktif biaya hutan tanaman", "Karya tulis ini murni hasil gagasan penelitian dan tulisan saya", "Karya tulis ini adalah murni gagasan rumusan dari penelitian saya", "Karya tulis ini adalah murni gagasan rumusan dan penelitian saya", "Karya ilmiah tersebut telah dipublikasikan di", "Kami menyampaikan penghargaan yang setinggi tingginya kepada seluruh penulis yang", "Kabbelwelle f Krauselwelle Rippel f rip ple The ruffling", "KURA に 登録 され て いる コンテンツ の 著作 権", "Jyvaskyla University of Jyvaskyla p Studies", "Jyvaskyla University of Jyvaskyla p Jyvaskyla Studies", "Jusu pasirinktas PDF failas turi buti atidaromas narsykles lange", "Just Accepted manuscripts have been peer reviewed", "Jurnal ini mengikuti pedoman dari Committee on Publication", "Jurnal ini memuat artikel hasil penelitian skripsi dan atau hasil", "Jurnal ini dilisensikan di bawah Creative Commons Attribution", "Jurnal Penelitian Pendidikan JPP is published since", "Jurnal Pelita Pendidikan is using", "Jurnal MD menerima tulisan mengenai hasil pemikiran", "Jurnal Kesehatan Lingkungan JKL articles published", "Jurnal ISTORIA merupakan journal yang diterbitkan", "Jurnal ILMU KOMUNIKASI has been published", "Jurnal Dharma Bakti nomor", "Jurnal Civic Education Media Kajian Pancasila dan Kewarganegaraan", "Joyful Learning Journal publishes research articles", "Journal of Research in Medical Sciences JRMS is aimed to", "Journal of Positive Psychology and Wellbeing ISSN", "Journal of Nepal Medical Association is the first and oldest", "Journal of Food Science Since the Journal of", "Journal of Experimental & Clinical Cancer Research is an online", "Journal of English Language and Education", "Jorunal of the Earthquake Engineering Society of Korea KCI 등재", "Jokaisesta Virittajassa julkaistavasta kirjoituksesta solmitaan lehden ja tekijan tai tekijoiden", "Join the Society for General Microbiology", "John Malcolm Fraser was born on May in", "Jfe Reproduction Series Our goal at", "JavaScript is disabled on your browser", "JSTOR is part of ITHAKA a not for profit organization", "JSTOR is a not for profit service that helps scholars researchers", "JSTOR is a not for profit organization founded in", "JSS publishes Open Access articles under the Creative Commons", "JSID Abstracts CALCIUM DEPENDENTPERIPHERALLOCALIZATIONOF LIKEPROTEINSIN", "JSAP was established as an official academic society", "JNPK is an organization that gathers experts", "JAXA 出版 物 種類 別 特別 資料 Special Publication 略称", "J STAGE is in maintenance mode from", "J GLOBAL では 研究 開発 で キー と なる 情報", "Izinkan kami memberikan penghargaan yang setinggi tingginya kepada pembicara utama", "Items in this repository are protected", "Items in this collection are for personal use only", "Items in the Loughborough University Institutional Repository", "Items in UNISA Institutional Repository are protected", "Items in OPUS are enhanced with high quality metadata and", "Items deposited in White Rose Research Online", "It seems that you are interested in educational research", "It is recommended that you start again from the homepage", "It is permitted to copy distribute display", "It is not permitted to download or to forward", "It is not necessary to obtain permission to reuse this article", "It is illegal to make unauthorized copies forward to", "It is a great pleasure to thank the volunteer reviewers", "It is a condition of publication in this journal", "Issues prior to Vol No have been digitized and made", "Irrevogavel e a decisao que acolhendo pedido de reconsideracao ordena", "Ipmi Repository is an online archive service", "Iowa State University Extension and Outreach publications", "Inventio publica articulos de divulgacion", "Internet Drafts are draft documents valid for", "International Journal of Scientific Engineering and Technology Research IJSETR is", "International Journal of Medicine and Public Health IJMEDPH", "Inter Research IR has granted permission to the University of", "Instytut Matematyczny jest jednym z głownych matematycznych osrodkow naukowych z", "Instituttet har som formal a drive forskning og utviklingsarbeid", "Institutional repository of IIT Bombay is a", "Ingenta is not the publisher", "Information provided on this web page is aggregated encyclopedic and", "Information management system of scientific activity is an open access", "Information identified as archived is provided for reference", "Information about obtaining registered trademark of AAAS", "Informa UK Limited trading as", "Indonesian Journal of International Law ijil is firstly published", "Individuals with paid subscriptions may search and access full articles", "Individual authors hold the copyright to articles published in", "Indicate by check mark whether the registrant l has filed", "Indicate by check mark whether the registrant has filed", "In today's market it is imperative to be knowledgeable and have an edge", "In this international seminar on Language Maintenance", "In the unlikely event that the author did not send", "In the address bar of your browser delete the end", "In presenting this thesis in partial fullfilment", "In presenting this thesis in partial fullfillment", "In presenting this thesis in partial fulfillment", "In presenting this dissertation in partial fullfilment", "In presenting this dissertation in partial fullfillment", "In presenting this dissertation in partial fulfilment", "In presenting this dissertation in partial fulfillment", "In presenting this dissertation as a partial fulfillment", "In order to supply better service for more reader", "In making Cultural Anthropology free to read we have given up our", "In den vom WIK herausgegebenen Diskussionsbeitragen erscheinen", "In copyright For permission to duplicate repost", "In case the submitted paper is accepted for publication", "In addition to working papers the NBER disseminates affiliates", "In Copyright URI https", "In Copyright URI http", "In Copyright Hamersly Library knows this item to be", "In April Manchester eScholar was replaced", "Important Disclaimer", "Images text or other content downloaded from the collection may", "Images provided for research and reference", "Im Rahmen der Registrierung muss der die Nutzer in", "Il s agit de construire un lien contenant les metadonnees et pointant vers le serveur OpenURL", "Il report seguente simula gli indicatori relativi alla propria produzione scientifica", "Il n ya pas de route royale pour la science", "Il est souhaitable qu une analyse syntaxique en traitement", "Il candidato dichiara che il presente lavoro e originale", "Il campo presenta il numero di citazioni presenti", "Il Centro di studi internazionali Giuseppe Ermini e un ente", "If your organization uses OpenAthens", "If your library subscribes to the ProQuest Dissertations", "If you would like to write for this or any", "If you would like to refine your search please go", "If you would like full access to this publication", "If you wish to reuse any or all of this article", "If you wish to have the full texts of the", "If you wish to find additional or newer e books", "If you think this list is incomplete then please click", "If you see this message your web browser", "If you re seeing this page", "If you know of missing items citing this one", "If you have the appropriate software installed", "If you have problems with the site difficulty accessing portions of it", "If you have found an error in this guide", "If you have discovered material in AURA which is unlawful", "If you have difficultly logging in then clear your browser cache", "If you have authored this item", "If you have any trouble locating the hospital", "If you have an individual subscription", "If you have an individual access to this publication", "If you have access to a journal via a society", "If you got here by following a link", "If you go to your temporary url", "If you find problems when you try to log in to our system", "If you find content within the University of", "If you find content within Ball State University", "If you experience problems downloading a file", "If you encounter problems with the site or have comments", "If you encounter harmful or offensive content or language", "If you don t currently have access please recommend this", "If you do not have access to the article", "If you believe that your copyright protected work has been", "If you believe that digital publication of certain material infringes", "If you believe that any material in VTechWorks", "If you believe that any material held in STORRE infringes", "If you believe that any material held in Apollo infringes", "If you are the author of this thesis", "If you are the author of this article", "If you are supporting DoD or US Government research", "If you are requesting permission to reprint DUP material", "If you are not at the University of Michigan", "If you are not a current K College student faculty", "If you are having problems viewing this page", "If you are experiencing problems downloading", "If you are an organization which has multiple links on", "If you are an ACOG Fellow", "If you are already logged in but arrive at this page", "If you are a registered user and would like to help", "If you are a UNNC author please login", "If you answered NO honestly to all PAR Q questions", "If visible please click on the Kudos Summary button", "If these materials are helpful to you please consider making", "If there are further questions regarding the UMPO Scientific Journal", "If the submission contains material for which you do not", "If the ebook is available on an edevice", "If the administrators of Newcastle University Theses Repository", "If not otherwise stated by the Publisher's Terms", "If no author name is available use the first few", "If I have indicated my intention to copyright this thesis", "Identifier s exotic organisms exotic species", "Identifier This image is held at the Cory Library", "Ich erklare ehrenwortlich dass ich die vorliegende Arbeit", "Ich erklare Ich habe die vorgelegte Dissertation selbstandig", "Ich erklare Ich habe die vorgelegte Dissertation", "Ibero Americana Pragensia is a professional peer reviewed journal", "IZJAVLJAM da sem predlozeno delo pripravila samostojno", "IZJAVLJAM da sem predlozeno delo pripravil samostojno", "IUSLabor es una revista de acceso abierto", "ISSN ESSN", "ISBN Pbk", "ISBN Hbk", "IS&T grants to purchasers of its publications limited license", "IRIS e la soluzione IT che facilita la raccolta", "IPEM's aim is to promote the advancement of physics", "INSTYTUT MATEMATYCZNY PAN", "IMPORTANT This version of an accepted manuscript", "IMPORTANT NOTICE The Government Printing Works will not be held", "IHDR3Z Zn sRGB Θ gAMA A ⁿa IDATx φ i U wvv Ka e v½P", "IF YOU WOULD PREFER to have your CD Summary delivered", "IF YOU BELIEVE THAT WE HAVE MADE AN ERROR", "IET members benefit from discounts to all IET publications", "IESD The Institute s work focuses on the clean efficient", "IEEE Personal use of this material is permitted", "IE10 以前のブラウザをご利用の方へ", "IDEALS migrated to a new platform", "ICoTE Proceedings publishes articles submitted to editors", "IBMS BoneKEy provides a breadth and depth of coverage of", "IAEE publishes three publications throughout the year", "I ゥサレ Vt カ 椅 コ T 塚 y ッ ム セ 雉 匤 サル 闖 カ リ 件 ァ ァ ト R エ N キ", "I Ως κατοχος των πνευματικων δικαιωματων αυτης της διατριβης", "I write this preface from the state of", "I would like to thank my advisor", "I would like to give high appreciation to", "I would like to express my sincere gratitude", "I would like to express my sincere appreciation", "I would like to express my heartfelt thanks", "I would like to express my gratitude", "I would like to express my deepest gratitude", "I would like to express my appreciation to", "I understand that I must submit a print copy", "I represent that my thesis or dissertation", "I prepared or cooperated in the preparation", "I nara samarbete med medicinteknisk industri och medicinska kliniker arbetar", "I metadati riferimenti bibliografici inclusi possono essere riutilizzati", "I metadati presenti in IRIS UNIMORE", "I hereby guarantee that no part of the", "I hereby grant to Simon Fraser University", "I hereby declare that this thesis is my own work", "I hereby declare that this thesis is my original work", "I hereby declare that this submission is my own work", "I hereby declare that all information in this document has", "I hereby declare that I am the sole author", "I hereby certify that the work embodied in the thesis is my own work", "I hereby certify that the work embodied in the thesis", "I hereby certify that if appropriate I have obtained and", "I hereby assign to the SOUTH AFRICAN SOCIETY FOR ENOLOGY", "I hereby agree that the material mentioned", "I have reviewed the submission and I support its inclusion", "I grant to the Undergraduate Research Journal of History the", "I grant to Case Western Reserve University the right to", "I declare we declare that the text submitted here", "I declare that this thesis represents my own work", "I declare that this thesis is my own work and", "I declare that this dissertation represents", "I declare that the work in this thesis was carried", "I declare that the thesis is my original work", "I confirm that I request a copy of this work", "I certify that to the best of my knowledge", "I certify that this work contains no material which has", "I certify that this undergraduate thesis contains", "I certify that this thesis and the research to which", "I certify that this student has met", "I certify that these students have met the requirements for", "I certify that the work in this thesis has not", "I certify that the content of the thesis", "I certify that hard copies of the approval page for", "I certify that except where due acknowledgement has been made", "I authorize the American University of Beirut to a reproduce", "I assume the risk of exhibiting my work on the", "I am the sole author writer of this Work", "I also certify that the thesis has been written by", "I acknowledge that I retain ownership rights to the copyright", "I We hereby declare that the material being presented by", "I The sign or target for pages apparently lacking", "I Garantizar el pleno ejercicio de los derechos sociales consagrados", "I El articulo de la Constitucion espanola proclama el", "I As the sole owner of this dissertation", "I As sole owner of this dissertation", "Hvis du vil ha mer informasjon om hvordan du skriver ut lagrer", "How to cite print and electronic books and journals", "How to Cite En caso de hacer uso parcial o total del contenido", "How do I set e mail notifications", "History Articles Received", "Historical Studies in Education Revue d histoire de l education", "Historia del articulo Recibido", "Historia Mexicana El Colegio de Mexico Vol Num", "Het PDF bestand dat u gekozen hebt zal hier geladen", "Het OWA het open archief van het", "Het College van bestuur van de Open Universiteit heeft prof", "Heriot Watt University has made every reasonable effort to ensure", "Here you can find all Crossref listed publications", "Her hakkı saklıdır Yazarından ve yayınevinden", "Henrotin Yves mailto", "Henrotin Yves Universite de", "Hecho en Mexico ©", "Hecho en Mexico todos los derechos reservados", "Hecho en Mexico Universidad Nacional Autonoma", "Hecho en Mexico Direccion General de la Escuela", "Hecho en Mexico Algunos derechos reservados", "Healthcare Access and Quality Index based on mortality from causes amenable to personal health care in countries and territories", "Headlines editorial reports and material enclosed in brackets are supplied", "He mencionado todas las fuentes empleadas en el presente trabajo", "Having learned a lot over the last 4 years we have updated the design of our preprint", "Haute ecole de gestion de Geneve Haute ecole de", "Harvested from web on", "Hard copies of all theses are available for loan", "Hal est destine au depot de contenus scientifiques que leurs", "Hak cipta pada setiap artikel adalah milik penulis", "Hak cipta artikel artikel pada jurnal ini dimiliki oleh", "HMP Communications LLC HMP is the authoritative source for", "HAL is a multi disciplinary open access", "HAL UPS est la plate forme d auto archivage de", "HAL Polytechnique est la plate forme de depot et de", "HAL IN2P3 est une des composantes de l archive", "H04B Элементы передающих систем не предусмотренные только однои из групп", "H03K Электронная коммутация или стробирование т е без размыкания или", "H03K Схемы для генерирования электрических импульсов моностабильные бистабильные или мультистабильные", "H02P Устроиства для регулирования или управления скоростью вращения или крутящим", "H02M Преобразование энергии постоянного тока на входе в энергию постоянного", "H02M Преобразование энергии переменного тока на входе в энергию постоянного", "H02M Преобразование энергии переменного тока на входе в энергию переменного", "H02H Схемы защиты осуществляющие автоматическое отключение и непосредственно реагирующие на", "H02H Схемы защиты для конкретных типов электрических машин и аппаратов", "H01L Способы и устроиства специально предназначенные для изготовления или обработки", "H01J Способы и устроиства специально предназначенные для изготовления электронных или", "H01H Высоковольтные или сильноточные выключатели с устроиствами для гашения или", "H Κεφαλη Μεδουσας ειναι μια υπερμεγεθης κεφαλη που", "Gut and Liver is an international journal of gastroenterology", "Groenekennis bevat artikelen uit vaktijdschriften", "Grenoble Sciences poursuit un triple objectif realiser des ouvrages correspondant", "Great care is taken in the compilation and production of", "Grant of Rights Author hereby grants to ProQuest", "Gran parte del analisis economico es ineludiblemente matematico por cuanto", "Gracias por descargar con Public Knowledge Project su Open Journal", "Globally hepatocellular carcinoma HCC is the 3rd leading reason for", "Global regional and national life expectancy all cause mortality and", "Gli autori possono diffondere la loro opera", "Gli autori possono aderire ad altri accordi di licenza", "Gli autori mantengono i diritti sulla loro opera", "Given the primary mission of libraries archives and documentation centers", "Gewahrt wird ein nicht exklusives nicht ubertragbares personliches und beschranktes", "Geosciences Rennes fait partie de l Observatoire des Sciences", "Georg Thieme Verlag Rudigerstr", "Geophysical Research Letters publishes high impact", "General rights lt is not permitted to download", "General rights Unless", "General rights Copyright for the publications made accessible", "General rights Copyright and moral rights for the publications made", "Gegenstand dieser Arbeit ist die Religion der Kelten so wie", "GateD software copyright The Regents of the University All", "Garantizo la originalidad de la obra gozo de la libre", "Gaipel m Gopel Gaize f feinklastisches Gestein n gaize A", "GUIDELINES FOR LETTERS Letters discussing a recent JAMA article", "G11B Привод пуск или остановка носителеи записи выполненных в форме", "G11B Запись путем намагничивания или размагничивания носителя информации воспроизведение с", "G06G Устроиства в которых вычислительные операции выполняются", "G06F Способы и устроиства для обработки данных с воздеиствием на", "G06F Вводные устроиства для передачи данных подлежащих преобразованию в форму", "G05F Автоматические системы в которых отклонения электрическои величины от одного", "G05B Системы программного управления электрические числовое управление", "G01V Разведка или обнаружение с помощью электрических или магнитных средств", "G01R Устроиства для определения электрических своиств устроиства для определения местоположения", "G01R Устроиства для измерения активного реактивного и полного сопротивления или", "G01P Измерение скорости текучих сред например воздушных потоков измерение скорости", "G01P Измерение линеинои или угловои скорости измерение разности различных линеиных", "G01N Исследование или анализ материалов с помощью электрических электрохимических или", "G01N Исследование или анализ материалов с помощью ультразвуковых звуковых или", "G01N Исследование или анализ материалов с помощью оптических средств т", "G01N Исследование или анализ материалов с помощью волнового или корпускулярного", "G01N Исследование или анализ материалов особыми способами не отнесенными к", "G01L Измерение постоянного или медленно меняющегося давления газообразных и жидких", "G01F Индикация или измерение уровня жидких газообразных или сыпучих тел", "G01F Измерение объема или массы жидкостеи газов или сыпучих тел", "G01F Дозаторы с внешним управлением для повторяющегося отмеривания и выдачи", "G01D Передача выходного сигнала от датчика с использованием механических средств", "Fur Inhalt und Verteilung der Kieler Arbeitspapiere ist", "Fur Dokumente die in elektronischer Form auf diesem Dokumentenserver bereitgestellt", "Funding and support by", "Fulltext ar ej tillganglig i elektronisk form For lan kopia", "FullText pdf ©", "FullText pdf Copyright ©", "Full texts https", "Full texts http", "Full text versions are not available", "Full text of this publication does not contain sufficient affiliation", "Full text is provided in Portable Document Format", "Full text is available as a scanned copy", "Full text is available as a scanned cop", "Full text for this publication is not currently held", "Full text downloads displays the total number", "Full membership to the IDM is for researchers", "Full issue available from nugserie", "Full details on how to submit material for publication", "Frontiers is more than just an open access publisher", "From the critical edition of Secondo libro", "From the critical edition of Primero libro", "From MEDLINE PubMed a database of the US National Library", "From January RAA's international publisher IOP Publishing", "Frequency of updates In most cases our metrics data is updated", "Fragrance journal Research & development", "Founded in Spontaneous Generations is an online academic journal", "Founded in Society for Science the Public's mission is to advance", "Founded in Society for Science & the Public's mission", "Founded by Emmanuel Mounier in this journal", "Fotocopie per uso personale del lettore possono essere effettuate nei", "Format diketik bukan tulisan tangan", "Forma y Funcion esta suscrita al convenio Open Journal System", "Forfatteren forfatterne og tidsskriftet deler copyrighten", "Forfatteren forfatterne og NyS har ophavsret", "Forfattere kan indga flere separate kontraktlige aftaler", "Forfattere har ret til og opfordres til at publicere deres", "Forfattere bevarer deres ophavsret", "For urgently needed technical support phone", "For translation rights requests contact", "For technical questions regarding this item", "For reviews All reviews commissioned by Homiletic are the exclusive", "For permission to photocopy or use material electronically from this", "For pdfs of all publications click here", "For more information about copyright for materials within the Archives", "For more communications and information on this journal please contact", "For further information about our publishing program consult our website", "For free distribution This work may be republished", "For electronic format permissions Requestor agrees to provide a hyperlink", "For electronic format permissions", "For each type of source in this guide the general", "For current publications please contact the Education Store", "For copy requests please submit the Request for Digital Objects", "For content published in editions of JAIS before copyright", "For articles with up to illustrations images may be", "For articles All articles published in", "For any reuse or distribution you must make clear to", "For all articles published in MEJ copyright", "For all articles published in Journal of", "For all articles published in JP2SD copyright", "For abstract see issue p Accession", "For a kunne apne dokumentet ma du ha en PDF leser", "For a kunne apne dokumentet ma du ha Adobe Reader", "For AU Library databases if the full text article is", "Follow this and additional works at https", "Follow this and additional works at http", "Follow this and additional works at htp", "Florida State University theses and dissertations completed", "Fiskeriforskning Norsk institutt for fiskeri", "First published in Routledge is an imprint", "First of all lets say Thanks to", "First of all let s say Thanks to", "First of all I would like to thank", "First of all I would like to express", "First and foremost I would like to thank", "First and foremost I would like to express", "First Page of the Article", "Find the journal you want to submit", "Fill in the request form below Email is necessary for", "File scanned at ppi Moochrome", "File scanned at ppi Monochrome", "File scanned at ppi Grayscale", "File scanned at ppi 24bit Color", "Figure Partial H NMR spectra MHz", "Figure 1D N NMR spectra of", "Federal government websites always use a gov or mil domain", "Fecha de publicacion nov Citacion Repertorio", "Fecha de publicacion Editor a Universidad de", "Favor indicar impreterivelmente o nome", "Fait partie d un numero thematique Correspondance administrative sous le", "Fair use You do not need permission to use this", "FPL's mission is to identify and conduct innovative wood and", "FORUM claims non exclusive rights to reproduce", "FELIPE DE JESUS CALDERON HINOJOSA Presidente de los Estados Unidos", "F27B Печи с механическим перемещением нагреваемого материала например туннельные печи", "F16D Податливые невыключаемые муфты т е муфты со средствами допускающими", "F04F Струиные насосы т е устроиства в которых поток текучеи", "External access to full text versions of articles and conference contributions is conditioned to the original publisher's copyright policy", "Extended abstract of a paper presented at", "Export the current results", "Export the current item", "Excepto si se senala otra cosa la licencia del", "Except where otherwise noted this work is subject", "Except where otherwise noted this item's license", "Except as permitted under the Act no part of this", "Except as permitted under US Copyright Law no part", "Except as otherwise expressly provided the authors of each article have granted permission", "Except as otherwise expressly provided the author of each article in this volume has granted", "Exact publication date is unknown but is estimated to", "Everything we publish is freely available", "Everybody may disseminate this article", "Every reasonable effort has been made to ensure that permission", "Every item on Calisphere has been contributed to the site", "Every account receives 1500MB space", "Europe PMC is a service of the Europe PMC", "Estudos da Lingua gem mantem os direitos autorais", "Estudios de Cultura Maya es una revista cientifica semestral", "Estou ciente de que em sendo aprovado a publicacao", "Estos usos podran ser ejercidos directamente por la Fundacion Universitaria", "Estimados y Estimadas les comunicamos que la recepcion de textos se encontrara", "Estimados as queremos informar la suspension de acceso", "Este viernes de noviembre a las hrs Chile se realizara", "Este trabalho foi recuperado de uma versao anterior da revista", "Este trabalho esta sendo submetido a aprovacao", "Este trabajo forma parte de la revista", "Este sitio web usa cookies", "Este sitio web apoya la publicacion en linea de revistas latinoamericanas", "Este sitio puede ser reproducido con fines no lucrativos", "Este repositorio institucional e regido pela", "Este repositorio elaborado en base a software", "Este portal y sus contenidos pueden ser reproducidos", "Este portal utiliza o Open Journal Systems OJS x sistema", "Este portal utiliza o Manakin", "Este portal esta usando o Manakin", "Este portal e regido pela Politica de Acesso Aberto ao Conhecimento que", "Este documento esta sujeto a una licencia de", "Este documento es una pagina web de un solo", "Este contenido esta publicado bajo la licencia", "Estas cookies y otras tecnologias son esenciales para que el", "Estamos a optimizar o servico para melhorar a sua fiabilidade", "Esta web utiliza cookies propias", "Esta web utiliza cookies", "Esta web usa cookies", "Esta web esta usando Manakin", "Esta version digital ha sido acreditada bajo la licencia", "Esta tesis solo esta en formato papel", "Esta tesis en PDF no tiene permisos", "Esta revista y sus articulos se publican bajo la licencia", "Esta revista y su contenido se brindan bajo una Licencia", "Esta revista utiliza o sistema LOCKSS", "Esta revista puede ser reproducida con fines no lucrativos", "Esta revista proporciona un acceso abierto inmediato", "Esta revista es la publicacion semestral de la Facultad", "Esta revista de formato digital se publica de manera desinteresada", "Esta publicacion es un producto compilado y editado", "Esta pagina puede ser reproducida", "Esta obra ha sido publicada bajo la licencia Creative Commons", "Esta obra esta sujeta a una licencia", "Esta obra esta bajo una licencia de", "Esta obra esta bajo licencia internacional Creative Commons", "Esta licencia permite copiar distribuir", "Esta licencia permite compartir copiar", "Esta licencia no permite la generacion de obras derivadas", "Esta licenca permite remixe adaptacao e criacao", "Esta licenca permite que outros distribuam", "Esta es una revista electronica de acceso abierto", "Esta e uma copia digital de um documento", "Esta Tesis se presenta como parte de los requisitos", "Esta Newsletter NL resulta de uma parceria entre o Instituto", "Essays and articles published in The Looking Glass may be", "Es posible copiar comunicar y distribuir publicamente su contenido", "Es el patriarca de una familia que desde ofrece profesionales", "Es condicion para la publicacion que el autor", "Eres Telecharge le sur www cairn info", "Er vindt onderhoud plaats aan de", "EntreDiversidades Revista de Ciencias Sociales", "Enter your feedback below", "Enter your TIN in the appropriate box", "Enter the following information to request a copy", "Enstitu tarafından onaylanan lisansustu tezimin tamamını", "Enstitu tarafından onaylanan lisansustu tezimin raporumun tamamını", "English 年 获 中国 科技 期刊 国际 影响", "English If the article is accepted for publication all copyright", "English Education Department Collegiate Forum", "Enfoques Juridicos es una publicacion semestral digital arbitrada", "En nuestro repositorio institucional estan depositados materiales", "En mi calidad de autor hago entrega del trabajo de", "En los lugares distintos del en que se publique el", "En la Revista Umbral los articulos son evaluados por el", "En este trabajo se analiza la complejidad del concepto de", "En este sitio se pueden consultar las tesis de grado", "En el momento en que una obra es aceptada", "En el caso de que no encuentre el documento", "En el Repositorio Institucional Universidad Autonoma de Occidente estan depositados", "En deposant vos publications dans larchive ouverte HAL UG vous", "En caso de que el articulo sea publicado los autores", "En calidad de autor del articulo declaro que este es", "Emulations est une revue de sciences sociales internationale a peer", "Employed Bar Passage Required A position in this category requires", "Em conformidade com as resolucoes no do Conselho Federal", "Em alternativa pode transferir o ficheiro PDF", "Elsevier journal websites will be undergoing maintenance", "Els textos publicats en aquesta revista estan subjectes llevat", "Els textos i imatges publicats en aquesta obra", "Els textos es difondran amb la llicencia de reconeixement", "Els autors que publiquen en aquesta revista estan d acord", "Els autors ores conserven els drets d autor", "Els autors i les autores conserven els drets", "Els autors i autores son lliures de fer acords contractuals addicionals", "Els autors es conserven els drets d autor", "Els autors conserven els drets d autoria", "Els articles seran publicats amb llicencia", "Electronic theses and dissertations available in The University of Western", "Electronic Supporting Information files are available", "Electronic Submittals At least one hard copy must be sent", "El usuario tiene la obligacion de utilizar los servicios", "El uso de esta plantilla esta limitada a los estudiantes", "El trabajo de grado que presentamos es original y basado", "El repositorio de la Universidad de Ibague es un sistema", "El proceso de evaluacion de articulos se realizara a traves", "El presente trabajo fue conducido en la", "El presente trabajo fue conducido en el", "El presente documento es difundido por la Universidad", "El o los autores otorgan licencia de uso parcial", "El material que se publica en esta Revista esta bajo", "El material contenido en la revista puede ser distribuido", "El los autor es otorga n licencia de uso parcial", "El licenciante no puede revocar estas libertades en tanto", "El la autor a o los as autores as conserva", "El la autor a cede en exclusiva todos", "El fitxer PDF que heu seleccionat es carregara si el", "El envio de un articulo para su publicacion implica", "El envio de propuestas de articulos para su publicacion en", "El copyright de los articulos e ilustraciones son propiedad de", "El contenido y las opiniones incluidas en los trabajos publicados", "El contenido que aparece en la Revista", "El contenido de los textos es responsabilidad de los autores", "El contenido de los articulos que se publican en cada", "El contenido de los articulos publicados es responsabilidad", "El contenido de los articulos es responsabilidad de los autores", "El contenido de la revista se puede compartir en cualquier", "El contenido de la Revista Horizonte Medico es de libre", "El comite editorial del Boletin de Filologia informa", "El aviso de Copyright abajo expuesto aparecera", "El autor que realiza el envio del presente articulo certifica", "El autor permite entremezclar ajustar y construir", "El autor otorga a la Direccion General de Investigacion", "El autor otorga a Procesos el derecho de copia Copyright", "El autor o los autores conserva n los derechos morales", "El autor o autores se compromete n a firmar", "El autor o autores deberan autorizar la publicacion", "El autor o autores de un articulo aceptado para publicacion", "El autor o autora conserva todos los derechos sobre", "El autor manifiesta que la obra objeto de la presente", "El autor es manifiesta n que la obra objeto de", "El autor cede los derechos de publicacion a la Escuela", "El archivo PDF que ha seleccionado se debe cargar aqui si su navegador", "El archivo PDF que ha seleccionado se debe cargar", "El acceso a todos los articulos de las revistas", "El Servicio de Publicaciones de la Universidad de Murcia", "El Repositorio Institucional tiene como objetivo almacenar", "El Repositorio Institucional del", "El Repositorio Institucional de la", "El Repositorio Institucional de Acceso Abierto de la Universidad", "El Repositorio Digital de la UMAZA adopta una licencia Licencia", "El Repositorio Digital Institucional de la", "El RII es un espacio para compartir y disponer de", "El Portafolio es un documento personal indispensable", "El Observatorio Social de America Latina OSAL constituye una iniciativa", "El Instituto de Investigaciones Esteticas de la UNAM es responsable", "El Instituto de Estudios Tirsianos del GRISO de la Universidad", "El Instituto Colombiano de Normas Tecnicas y Certificacion", "El Informe Estado de la Educacion tiene como objetivo fundamental", "El Indice Bibliografico Nacional Publindex es un sistema", "El Guiniguada se distribuye en abierto bajo una licencia Creative", "El Consejo de Universidades ha remitido a ANECA", "El Comite Editorial de la Revista le solicita muy comedidamente", "El Autor le concedera al Editor un derecho perpetuo y", "El Archivo del Patrimonio Fotografico y Filmico del Valle del Cauca", "El Anuario del Centro de Estudios Martianos es la publicacion", "El Anuario de Investigaciones de la Facultad de Psicologia aplica", "El AUTOR afirma que el articulo es inedito", "Ekonomsko poslovni fakulteti dovolim objavo", "Ekonomsko poslovni fakulteti dovolim ne dovolim", "Egerton Journal of Science and Technology", "Educational use only no other permissions", "Educational and scientific institutions are encouraged to reproduce", "Educacion y Humanismo proporciona un acceso abierto", "Educacion medica aplicaciones de la informatica medica", "Educacion Quimica ISSN", "Editorials published in", "Ecosistemas y Recursos Agropecuarios es una revista multidisciplinaria que se", "Ebene f Flachland Ebene f plane A two dimension form", "Earth Prints is an open archive", "Earn a master's degree in library or information science from", "Each thesis in the repository has been cleared where necessary", "Each chapter should be included in the main document as", "ESTUDIOS FRONTERIZOS Ano vol es una revista que publica", "ESTUDIOS DE ASIA Y AFRICA volumen numero", "ESTE DOCUMENTO TEM ACESSO RESTRITO", "ESTE DOCUMENTO ENCONTRA SE EM ACESSO RESTRITO", "ESC is an academic journal based at the University of", "EPAA AAPE is a peer reviewed open access international multilingual", "EL AUTOR autoriza a la Universidad Autonoma de Occidente", "EHP strives to ensure that all journal content", "EDITORIAL BOARD REVIEWER FOCUS & SCOPE PUBLICATION", "ECUADOR DEBATE Es una publicacion periodica del Centro Andino de", "EBLIP is a peer reviewed open access journal", "EARI Educacion Artistica Revista de Investigacion es una publicacion", "E21B Спуско подъемные операции с буровыми штангами обсадными трубами подъемными", "E21B Способы или устроиства для добычи нефти газа воды растворимых", "E04B Строительные конструкции общего назначения сооружения не обуславливаемые конструкциеи стен", "E01C Машины вспомогательные устроиства и инструменты для приготовления и распределения", "E ユ ェ mam ニ 婦 ョ 代 l 彼 敷 ィ 庸 S f 珵 T ミ 勺 掲 AI J T 鮗 K", "E uma publicacao cientifica editada pelo Instituto de", "E presente una richiesta di inserimento in ANCE", "E possivel efetuar a busca de um autor ou um", "E obrigatorio o envio do documento Declaracao para Submissao", "E mail syxb cnpc com cn 编辑 部 syxb8 cnpc com cn", "E mail syxb cnpc com cn 编辑 部 syxb3 cnpc", "E compito della Repubblica rimuovere gli ostacoli di ordine economico", "E books purchased by the University of", "During your search of AJPH content", "During this time our websites will be offline", "During this time our website performance may be temporarily affected", "Due to the safety measures taken by the Government of Quebec", "Due to the popularity of the AAP Virtual Career Fair", "Due to the paper quality and color choices of this", "Due to the large file size for this document", "Due to the condition of the original material there are", "Due to the character of the original source materials and", "Due to the COVID 19 crisis the transition into subscription", "Due to technical complications view and download counts", "Due to necessary scheduled maintenance", "Due to maintenance the webshop is unavailable", "Due to a planned intervention the CERN Document Server", "Dspace est le depot numerique", "Drug Dosage The authors and the publisher have exerted every effort to ensure that drug selection", "Drets L acces als articles a text complet", "Drets El la autor a cede de forma no exclusiva", "Drets Copia permesa amb finalitat d studi o recerca", "Drets Copia permesa amb finalitat d estudi", "Drets Aquest document esta subjecte a una llicencia", "Downloaded from https www cambridge org core", "Downloaded From https jamanetwork com", "Downloaded From http jamanetwork com by a Google Scholar User", "Download Book KB As a courtesy to our readers", "Doutorado em Mathematiques et Applications Universite", "Douglas College respectfully acknowledges that our campuses", "Donors have transferred any applicable copyright", "Donor s have transferred any applicable copyright", "Donor has transferred any applicable copyright", "Donor Restoration Funded By Available for adoption", "Dokument cyfrowy wytworzony opracowany opublikowany oraz finansowany w ramach", "Does not exist on this server", "Documents in the AZGS Document Repository collection are made available", "Documents in HENRY are made available under the", "Documents de recerca Documents dels grups de recerca de la", "Do you have NeoReviews for", "Do you have AAP Grand Rounds for", "Distribution_Liability Although this data and information has been processed successfully", "Distribution electronique Cairn info pour", "Distinctions The most widely read cited and known", "Dissertation submitted for the degree of", "Display the entire Work for access only by", "Discussion Papers represent the authors personal opinions", "Discussion Papers present results of country analysis or research that", "Discussion Papers of DIW Berlin are indexed in RePEc", "Discussion Papers can be obtained in pdf form from", "Discussion Papers are preliminary materials circulated to stimulate discussion", "Discussion Papers are intended to make results of", "Discussion Papers are a series of manuscripts", "Discussion Papers Interdisciplinary Research Project", "Discussion Follow this and additional works at", "Discussion Copyright for each article published", "Discover a faster simpler path to publishing", "Disclosure The authors did not receive", "Disclosure The author did not receive any outside funding or", "Disclosure None of the authors received payments or services either", "Disclosure In support of their research for or preparation of", "DisclaimerTh e statements options and data contained in this publication are solely those of the individual authors", "Disclaimer Universiti Teknologi PETRONAS shall not be liable for any", "Disclaimer This work has been made available to the staff", "Disclaimer This website has been updated", "Disclaimer This report", "Disclaimer This publication", "Disclaimer This manuscript has been accepted for publication", "Disclaimer This guide contains a compilation of information", "Disclaimer This document was prepared as an account of work", "Disclaimer This document contains a student thesis", "Disclaimer This content was produced under US Department of Education", "Disclaimer The views expressed in this", "Disclaimer The views and opinions expressed or implied in the", "Disclaimer The statements options and data contained in this publication are solely those of the individual authors", "Disclaimer The statements opinions and data contained in this publication", "Disclaimer The publisher", "Disclaimer The opinions expressed", "Disclaimer The materials published", "Disclaimer The information in this document", "Disclaimer The e content is exclusively meant for academic purposes", "Disclaimer The e content is exclusive meant for academic purpose", "Disclaimer Complaints regulations", "Disclaimer Citations of sources conclusions or opinions expressed in", "Disclaimer Articles appearing in this Published Ahead of Print section", "Disclaimer Articles appearing in this Pre Publication section", "Disclaimer Articles appearing in this PRS Online First section", "Disclaimer Articles appearing in this Online First section", "Disclaimer Articles appearing in this Latest Articles section", "Disclaimer Anyone with a query or complaint", "Disclaimer Accepted unedited article not yet assigned to an issue", "Dis cus si on Papers are inten ded to make results", "Direitos autorais Este exemplar e de propriedade do", "Direitos Autorais para artigos publicados nesta revista sao", "Digitised from the original held in the", "DigitalCommons Cedarville provides a publication platform for fully open access", "Digital Repository of the Institute of", "Digital Library preserves and enables easy and open access to", "DigiCat Publishing presents to you", "Dieses Werk ist Teil der Buchreihe TREDITION CLASSICS Der Verlag", "Dieses Kapitel ist Teil des Digitalisierungsprojekts Springer Book Archives", "Dieses Dokument wurde zum personlichen Gebrauch heruntergeladen Vervielfaltigung nur mit", "Dieser Titel aus dem De Gruyter Verlagsarchiv ist digitalisiert", "Dieser Text wird unter einer Deposit Lizenz", "Dieser Buchtitel ist Teil des Digitalisierungsprojekts", "Dieser Artikel ist ein Open Access Artikel und steht unter", "Die vorliegende Dissertation entstand wahrend meiner Tatigkeit als wissenschaftlicher Mitarbeiter", "Die vorliegende Arbeit wurde im Wintersemester", "Die vorliegende Arbeit entstand wahrend meiner Tatigkeit als wissenschaftlicher Mitarbeiter", "Die Wiedergabe der veroffentlichten Artikel in jedweder", "Die Publikationsreihe BLUE GLOBE REPORT macht die Kompetenz und Vielfalt", "Die Dokumente in HENRY stehen unter der Creative Commons Lizenz", "Die Auswahl eines geeigneten Handschuhs ist nicht nur vom Material", "Dibuat dan diselesaikan sendiri dengan menggunakan", "Dette selvstendige arbeidet er gjennomført som ledd i masterstudiet", "Dette er en elektronisk serie fra som erstatter de tidligere", "Designations used by companies to distinguish their products are often claimed as trademarks", "Designations used by companies to distinguish their products are often", "Desde todas las revistas del Centro de Estudios", "Desde los enlaces que figuran en esta pagina", "Desde el Nº se traslado a la ciudad de", "Descriptors DEC BETA DECAY RADIOISOTOPES BETA MINUS DECAY RADIOISOTOPES BIOLOGICAL", "Description of the Cambridge Encyclopedia", "Description To access the full text of this article kindly", "Description This work was digitized and made available", "Description This work was digitised and made available", "Description This thesis was digitised for the purposes of", "Description This material is presented to ensure timely dissemination", "Description This is an author accepted manuscript version of an article", "Description The folders in this collection have been compressed", "Description The Journal of Cell Biology JCB is", "Description The American Historical Review AHR is the official publication", "Description Science founded by Thomas A Edison", "Description Science News has been published since This award", "Description Published by the American Institute of Biological Sciences AIBS", "Description On Wednesday October Rhodes University closed", "Description Limnology and Oceanography L&O has", "Description Infection Control and Hospital Epidemiology provides original peer reviewed", "Description IEEE Personal use of this material", "Description Founded in the Journal of Consumer Research publishes", "Description Additional material related to the topic s covered in", "Description Abstract It s Elementary is a series of essays", "Description A PROJECT SUBMITTED TO THE DEPARTMENT OF CHEMICAL ENGINEERING", "Descripcion Este articulo hace parte de la revista", "Descricao O Boletim Agrometeorologico e uma publicacao cujo objetivo", "Descricao Este video integra um conjunto de conteudos em linguagem", "Describe the organization's program service accomplishments for each of its", "Desarrollo Gerencial proporciona un acceso abierto e inmediato", "Derechos de autor EL AUTOR expresa que la obra objeto", "Derechos de autor Cuadernos del Caribe Excepto que se establezca", "Derechos autoriales y de reproducibilidad", "Derechos Reservados ©", "Derechos Reservados Centro Universitario", "Derechos La revista Politica y Gobierno autoriza", "Derechos La revista Istor revista de historia internacional autoriza", "Derechos La revista Gestion y Politica Publica autoriza a poner", "Derechos La revista Economia Mexicana Nueva Epoca autoriza", "Derechos L acces als articles a text complet inclosos", "Derechos Este material se ha elaborado en el marco de", "Derechos Este documento esta sujeto a una licencia", "Derechos El la autor a cede de forma no exclusiva", "Derechos Copia permesa amb finalitat d estudi o recerca", "Depuis lundi septembre l interface de depot de", "Deployed by more than libraries worldwide Dspace", "Department Civil Engineering", "Department Chemical Engineering", "Denna uppsats kan bestallas fran arkivet", "Dengan segala keridhaan hati penulis", "Dengan sebenarnya penulis menyatakan bahwa skripsi", "Dengan nama Allah Yang Maha Pengasih dan Penyayang", "Dengan ini saya menyatakan dengan sesungguhnya bahwa dalam skripsi ini", "Dengan ini saya menyatakan bahwa", "Dengan ini penulis menyatakan bahwa", "Dengan ini menyatakan bahwa skripsi ini adalah karya sendiri", "Dengan ini menyatakan bahwa saya telah menyusun skripsi", "Dengan ini menyatakan bahwa penulisan skripsi", "Dengan ini menyatakan bahwa hasil penulisan skripsi", "Dengan ini menyatakan bahwa dalam", "Dengan ini menyatakan bahwa Laporan tugas akhir yang", "Dengan ini menyatakan bahwa Karya tulis saya skripsi", "Dengan hak bebas royalti non eksklusif", "Den har webbplatsen anvander kakor cookies", "Den Buhnen und Vereinen gegenuber als Manuskript gedruckt", "Demi Allah Saya akui karya ini adalah hasil kerja saya", "Dedicated to the dissemination of scholarly and professional information", "Declaro tener pleno conocimiento de la obligacion", "Declaro que o trabalho submetido a Revista", "Declaro que o trabalho de minha autoria foi submetido", "Declaro que o presente artigo e original", "Declaro que o mesmo foi apresentado somente ao", "Declaro que la informacion contenida en la seccion", "Declaro que este relatorio e integralmente da minha autoria", "Declaro bajo juramento que La tesis es de mi autoria", "Declaro ainda que uma vez publicado na revista", "Declaro ainda que o presente artigo e original", "Declaramos nossa ciencia e concordancia com todo o processo editorial", "Declaraciones Bajo la gravedad del juramento manifiesto que a Soy", "Declaracao de Responsabilidade Certifico que participei da concepcao do trabalho", "De voorzitter memoreert dat sinds het e enjarige bestaan van", "De reeks Werkdocumenten bevat tussenresultaten van het onderzoek van de", "De nouvelles ressources editees par le ministere de l Enseignement", "De identificarse la presencia de fraude datos falsos plagio", "De identificarse la falta de fraude datos falsos plagio informacion", "De identificarse fraude datos falsos plagio informacion", "De fileserver TEX NL waarop algemeen te gebruiken TEX produkten staan De", "De conformidad con las normas nacionales e internacionales sobre derechos", "De begeleider en of auteur heeft geen toestemming gegeven tot het openbaar", "De acuerdo con nuestra politica Licencia", "De acuerdo con lo previsto en la Ley Organica de de diciembre de Proteccion", "De acuerdo con las leyes de derechos de autor", "De acuerdo con la legislacion de derechos de autor", "De acuerdo con la Licencia Creative Commons", "De acuerdo con el reglamento de la Escuela de Posgrado", "De acuerdo a la normativa de TFEs el repositorio no", "De acuerdo a la Ley de Derechos de Autor", "Daystar University seeks to develop managers professionals", "Datos de publicacion Actas de las", "Data provided are for informational purposes only", "Das Zentrum fur sozialpadagogische Forschung ZSPF ist eine Plattform zur", "Das Werk und seine Teile sind urheberrechtlich geschutzt", "Das Tatigkeitsfeld des Fraunhofer Instituts fur Techno und Wirtschaftsmathematik ITWM", "Dans le cadre de l open access week l equipe", "DUT udkommer elektronisk via Statsbibliotekets Open Journal System", "DUE TO COPYRIGHT RESTRICTIONS", "DU faculty and students may access digitized MRPs by logging", "DSpaceUnipr", "DSpace Trakya Universitesi nin Kurumsal Acık Erisim Sistemi", "DSpace CRIS consists of a data model describing objects of", "DR ©", "DR Universidad Nacional Autonoma de Mexico Ciudad", "DOIs and date of initial publication", "DOI 可強化引用精確性 增強學術圈連結", "DOE Public Access Gateway for Energy and Science DOE PAGES is the search tool", "DOE PAGES Beta contains an initial collection of journal articles", "DISSERTACAO SUBMETIDA AO CORPO DOCENTE DO", "DISCLAIMER This report was prepared as an account of work", "DERECHOS DE AUTOR los autores conservan todos sus derechos", "DECLARACION certifico que he contribuido directamente al contenido intelectual", "DECLARACAO DE RESPONSABILIDADE Certifico que participei da concepcao do trabalho", "DBpia 추천 논문 과 함께 다운 받은 논문 을 제공", "DBpia 와 구독 계약을 맺고 있는 학교 공공기관", "DBpia 는 표현의 자유를 존중함", "DBLP's bibliographic metadata records provided through", "DBLP License DBLP's bibliographic metadata records provided through", "DASH Delft Architectural Studies on Housing is a thematic serial", "D01H Вытяжные машины и устроиства вытяжные машины и приборы в", "Czasopismo naukowe Folia Iuridica Universitatis Wratislaviensis ukazuje sie", "Curtin would like to pay respect to", "Curtin University would like to pay our respect to", "Current usage metrics show cumulative count of Article Views", "Current Assignee The listed assignees may be inaccurate", "Cuando visita cualquier sitio web este puede", "Cualquier forma de reproduccion distribucion comunicacion publica o transformacion de", "Creative Commons The texts published in this", "Creative Commons License This work is licensed under", "Creative Commons License IIF Iktisat Isletme ve Finans", "Creative Commons License ACL materials", "Creative Commons Licence you must assume that re use is", "Creative Commons Corporation Creative Commons is not a law firm", "Creative Commons Attribution NonCommercial", "Creation of the English language versions of the articles published", "Creating new databases of archival papers in modern technology", "Creating a My CABI account lets you", "Created in this journal is published by the French National Family", "Created in by the Jesuits Revue Projet is an accessible", "Cowles Foundation Discussion Papers are preliminary materials circulated to stimulate", "Covering the IET s activities in member interviews", "Counterfutures peer reviewed and published", "Copyrigt is held by the", "Copyright©", "Copyrights of all materials published inA", "Copyrights of all materials published in", "Copyrighted material allows the", "Copyrighted Material For use only by", "Copyright ผล งาน วิชาการ เหล่า นี้", "Copyright ©", "Copyright transfer agreements are not obtained by The Open Journal", "Copyright to this collection is held by the interview participants", "Copyright to the audio resource and its transcript is held", "Copyright to original material in this document", "Copyright the Publisher Version archived for private", "Copyright retained by", "Copyright remains in the Authors names", "Copyright remains in the Author's name", "Copyright remains in the Author s name", "Copyright protected material", "Copyright protected by Montgomery County Archives Alabama", "Copyright protected by Mississippi State University", "Copyright protected Use of materials from this collection", "Copyright on articles is owned by the Journal", "Copyright of this work belongs to the author", "Copyright of this work belong to the authors", "Copyright of this thesis rests with the author", "Copyright of this journal is assigned to Jurnal Buana Informatika", "Copyright of these pre print articles are retained by", "Copyright of published materials is held by", "Copyright of published material is held by", "Copyright of materials in the James W Silver Collection remains", "Copyright of correspondence in the Western Union Telegram Collection remains", "Copyright of correspondence in the James Meredith Collection remains with", "Copyright of articles that appear in", "Copyright of articles published in the Australian and New Zealand", "Copyright of all work published here subsists in the authors", "Copyright of all work published here subsists in the author", "Copyright of all material published", "Copyright of Journal of", "Copyright of Full Text rests with the original copyright owner", "Copyright of Clemson University", "Copyright notice Unless transferred in writing to", "Copyright notice This website and its contents are subject to", "Copyright notice This material is presented to ensure timely dissemination", "Copyright is retained in accordance with US Copyright laws", "Copyright is retained by the first or sole author", "Copyright is retained by the authors", "Copyright is retained by the author", "Copyright is held by", "Copyright in this work is held by the authors", "Copyright in this work is held by the author", "Copyright in the material you requested is", "Copyright in materials derived from the", "Copyright held by the authors", "Copyright held by the author", "Copyright has not been transferred to the Regents", "Copyright for this work is held by the authors", "Copyright for this work is held by the author", "Copyright for this journal is vested with", "Copyright for the publications made accessible via", "Copyright for articles published in this journal", "Copyright for articles and reviews rests with the authors", "Copyright for any article published", "Copyright for Canadian Field Naturalist content is held", "Copyright comment This article is published under license to", "Copyright c pertence a Revista", "Copyright c The Authors", "Copyright c The Author", "Copyright c ProQuest", "Copyright c Elsevier Science", "Copyright by the authors", "Copyright by the author", "Copyright by the article author", "Copyright by the article Authors", "Copyright by the Board of Regents of the University of", "Copyright by the American Institute of Aeronautics", "Copyright by the American Bar Association", "Copyright by Yale University", "Copyright by UEU Library", "Copyright by McGraw Hill", "Copyright by Koninklijke Brill", "Copyright by International Business Machine", "Copyright by Alfred A Knopf", "Copyright by Addison Wesley", "Copyright and terms of use", "Copyright and reuse City Research Online aims to make research", "Copyright and reproduction All rights are reserved", "Copyright and permission requests", "Copyright and moral rights to this work are retained by", "Copyright and moral rights to this thesis research project", "Copyright and moral rights for the publications made accessible", "Copyright and intellectual property rights for the publications made accessible", "Copyright and database rights in this material belong to", "Copyright and Reprint Permissions", "Copyright and Reprint Permission", "Copyright and Copying in any format Copyright", "Copyright Unless explicitly noted otherwise in the text this manuscript has been authored by", "Copyright University of Toronto Press", "Copyright University of Hertfordshire", "Copyright Trustees of", "Copyright This is an open access article", "Copyright The researcher assumes full responsibility for conforming with the", "Copyright The National Council of Teachers of Mathematics", "Copyright The Geological Society of America GSA", "Copyright The Authors", "Copyright The Author", "Copyright Swinburne University of Technology has been unable to trace", "Copyright Statement ©", "Copyright Statement This work is licensed under", "Copyright Statement This is an open access article", "Copyright Statement This article is distributed under the terms", "Copyright Psychoanalytic Electronic Publishing", "Copyright Other than for strictly personal use", "Copyright O INSPER E ESTE REPOSITORIO NAO DETEM OS DIREITOS", "Copyright Notice ©", "Copyright Notice Since these papers are published", "Copyright Notice Digital copies of this work", "Copyright Notice All rights reserved", "Copyright Marian University", "Copyright Lippincott Williams Wilkins", "Copyright Institute of", "Copyright IEEE", "Copyright Hindawi Publishing Corporation", "Copyright Hilandar Research Library", "Copyright G and Moral Rights for this thesis are retained", "Copyright G ProGuest", "Copyright FrancoAngeli NB Copia ad uso personale E vietata la", "Copyright Elsevier This manuscript", "Copyright Elsevier Limited except", "Copyright Copyright to the audio resource and its transcript is", "Copyright Copyright is retained by the author", "Copyright Boston University", "Copyright BiblioLife LLC", "Copyright American Institute of Physics", "Copyright All articles copyright", "Copying our publications in whole or in part", "Copying of material in this book for", "Copying and permissions notice Authorization to copy this content beyond", "Copies of translations in this series are made available to interested organizations", "Copies of all the documents submitted by the applicant", "Copia permesa nomes amb finalitat d estudi", "Cooperative Research Programs CRP grants permission to reproduce", "Cookies we do not use cookies to", "Cookies are small text files that are placed on your computer by websites", "Cookie が この サイト により 設定 され ます 拒否 する", "Controleer het adres in de adresbalk Soms gaat er iets", "Contribuye al desarrollo economico y social de la nacion a", "Contributors grant the TLR a right to publish their work", "Contributed by the Turbomachinery Committee of ASME for publication in", "Contributed by the Structures and Dynamics Committee of ASME for", "Contributed by the Solar Energy Division of ASME for publication", "Contributed by the Petroleum Division of ASME for publication in", "Contributed by the Ocean Offshore and Arctic Engineering Division of", "Contributed by the Materials Division of ASME for publication in", "Contributed by the Manufacturing Engineering Division of ASME for publication", "Contributed by the International Gas Turbine Institute IGTI of ASME", "Contributed by the Heat Transfer Division of ASME for publication", "Contributed by the Dynamic Systems Division of ASME for publication", "Contributed by the Design Engineering Division of ASME for publication", "Contributed by the Computers and Information Division of ASME for", "Contributed by the Combustion and Fuels Committee of ASME", "Contributed by the Bioengineering Division of ASME for publication", "Contributed by the Advanced Energy Systems Division of ASME for", "Contribute to the Repository", "Contiene una amplia recopilacion de documentos y articulos que dan cuenta", "Contents published in Digithum are subject to", "Content may be used for teaching research and educational purposes", "Content in the UH Research Archive is made available", "Content in the Kent Academic Repository is made available", "Content in the History Cooperative database is intended for personal", "Content from this work may be used under the terms", "Content Disclaimer The primary source materials contained in the Fort", "Contains Parliamentary information licensed under", "Consultants Bureau a division of Plenum Publishing", "Conservaran sus derechos de auto", "Conservan los derechos de autor ay ceden", "Conocer en profundidad los distintos modelos de evaluacion e intervencion", "Conforme con las Politicas de Acceso Abierto", "Conflicts of interest comprise financial interests activities and relationships", "Conditions of Use This image is provided for research", "Conditions of Use This image is out of copyright", "Conditions of Use Copyright owned by", "Condiciones de auto archivo Se permite y se anima a los autores a difundir", "Conclusions in this article are those of the author", "Con la entrega del trabajo se entiende que los autores", "Con esta autorizacion hago entrega del trabajo de grado investigacion", "Con esta autorizacion hago entrega del documento Trabajo de Grado", "Como titular es del os derecho s de autor confiero", "Como respetamos su derecho a la privacidad", "Como citar En caso de hacer uso parcial o total", "Como alternativa pode se baixar o arquivo PDF para o computador", "Comments and suggestions regarding this draft document should be submitted", "Comment partager les donnees liees a vos publications scientifiques", "Com essa licenca e permitido acessar baixar download", "College & Research Libraries News C&RL News is the official", "Collected Essays on Learning and Teaching CELT publishes", "Colby College theses are protected by copyright", "Codigo de etica y buenas practicas editoriales", "Close this message to accept cookies", "Clausula de Garantia Las opiniones vertidas en los articulos", "Claremont Graduate University his Article is brought to you", "Clackamas Community College does not discriminate on the basis of", "Citing your sources is an important part of the scholarly", "Citing this paper Please note that where the full text provided on", "Citing this paper Please note that where the full text", "Citations of this electronic publication should be made", "Citations are the number of other", "Citation Научныи потенциал молодежи будущему", "Citation only Full text article is available through licensed access", "Citation of documents Please do not cite the URL", "Citace zdrojoveho dokumentu", "Ciencias Veterinarias Journal authorizes the printing of articles", "Ciencia Juridica Ano no enero junio de es una publicacion", "CiNii Books における 内容検索の対象追加", "CiNii Articles における", "Chinese Medical Association This is an open access", "Chercheurs et doctorants deposez vos fichiers de texte", "ChemInform is a weekly Abstracting Service delivering concise information", "ChemInform Abstract ChemInform is a weekly Abstracting Service", "Check the information on costs for open access publishing", "Chao ban tai cho cong nghe va thiet bi Viet", "Chao ban tai Cho cong nghe thiet bi Viet Nam", "Chao ban cung cap va chuyen giao cac may moc", "Cette these est le fruit d un long travail approuve", "Cette these d exercice est le fruit d un travail", "Cette publication phare presente aux l investisseurs etrangers", "Cette page Web archivee demeure en ligne a des fins", "Cet ouvrage est une reedition numerique", "Cet article ne contienant pas de resume l image ci", "Cereals Grains Association is proud to present nearly books", "Cerca una tesi Come scrivere una tesi", "Center toll free at or ext outside the United States", "Cedo los derechos patrimoniales de mi", "Cedo los Derechos en linea patrimoniales de mi", "Ceci n est pas la version la plus recente", "Ce texte publie par les Editions Publibook est protege par", "Ce message apparait peut etre en raison d une inadaptation", "Ce journal dedie aux corps gras et aux lipides propose", "Ce bi zeleli vec informacij o tem kako dokument", "Catarata Glaucoma agudeza visual calidad de vida", "Cat inist is made up of over million bibliographic records", "Casopis Ibero Americana Pragensia je odborny recenzovany casopis", "Carleton College does not own the copyright to this work", "Carbohydrate Chemistry provides review coverage of all publications", "Can cu vao Hien phap nuoc Cong hoa xa hoi", "Can cu Nghi đinh so NĐ CP ngay thang nam cua Chinh phu", "Can cu Luat To chuc Chinh phu ngay thang", "Can cu Hien phap nuoc Cong hoa xa hoi chu", "Cairn int info uses cookies", "Cairn info vous permet de consulter en ligne un nombre croissant", "Cairn info utilise des cookies", "Cairn info est un portail de sciences humaines et sociales", "Cada manuscrito se acompanara de una declaracion en la que", "Cada autor se compromete a ceder de manera expresa los", "Cable Stitch n A knit effect produced by crossing a", "CSIRO Publishing blank image", "CSIC Los originales publicados en las ediciones impresa y electronica", "CRONIA sostiene su compromiso con las politicas de Acceso Abierto", "CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES", "COVID adolescencia adolescentes adulto", "COVID COVID PerAº SARS", "COPYRIGHT ll material supplied via Goldsmiths Library and Goldsmiths Research", "COPYRIGHT NOTICE Published by Princeton University Press", "COPYRIGHT All rights reserved", "COPYRIGHT AND PERMISSIONS The Journal of", "COORDENADAS sostiene su compromiso con las politicas de Acceso Abierto", "CNRS Unless otherwise stated above the content of this bibliographic", "CNIS Institut national de la statistique", "CIMMYT manages Intellectual Assets as International Public Goods The user", "CIFOR advances human wellbeing environmental conservation and equity by conducting research", "CIDADES esta licenciada sob a licenca Creative Commons", "CI ræ º o e r H oA yq Q", "CF2 publica los trabajos bajo una licencia Creative Commons", "CEH Topics & Objectives Water WA Topic", "CEEOL is a leading provider of academic e journals", "CC BY NC ND Users may download and share copies", "CBSM e um periodico destinado a difusao do conhecimento", "CARACTERIZACION DE LA EPILEPSIA BENIGNA DE LA INFANCIA CON PUNTAS", "C12N Микроорганизмы например простеишие их композиции способы размножения содержания или", "C09K Составы для бурения скважин составы для обработки буровых скважин", "C08L Композиции гомополимеров или сополимеров соединении содержащих один или более", "C08F Сополимеры соединении содержащих один или более ненасыщенных алифатических радикалов", "C08F Гомополимеры соединении содержащих один или более ненасыщенных", "C07F Соединения содержащие элементы V или XV группы периодическои системы", "C07C Эфиры карбоновых кислот эфиры угольнои или галогензамещеннои муравьинои кислоты", "C04B Формованные керамические изделия характеризуемые их составом керамические составы обработка", "Bład podłaczenia do hosta Can t connect to MySQL server", "By using this website you agree", "By using these files users agree to the Terms", "By the end of the school year in", "By the authority vested in me as President by the", "By submitting to the Journal the author grants to the", "By submitting to Teaching History the authors agree to", "By submitting to Teaching History the author s agree to", "By submitting to Fine Focus the authors agree to", "By submitting to Fine Focus the author s agree to", "By submitting this thesis electronically I declare that", "By submitting this license you the authors or copyright", "By submitting this license you the author s or copyright", "By submitting this license you the author or copyright", "By submitting the article for evaluation and subsequent publication in", "By submitting manuscripts to SAPJ authors", "By submitting manuscripts to SAJAA authors", "By submitting manuscripts to SAFP authors", "By submitting his her work to the Editorial Board", "By submitting a comment you agree to abide by our terms", "By signing your name below you affirm that this work", "By downloading any item s listed above you acknowledge that", "By default clicking on the export buttons", "By default all revisions archived after the launch of PhilArchive", "By default all copies archived after the launch", "By creating an account you agree to SSRN's Terms", "By clicking the Accept button or continuing to browse our site", "By clicking on the buttons below you consent to", "By clicking download a new tab will open", "Burns & Trauma 全球 儿童 安全 组织 上海 瑞金 医院", "Bundesgesetz Wertpapieraufsichtsgesetz WAG sowie Anderung des Bankwesengesetzes", "Buku teks pelajaran yang telah dialihkan hak ciptanya kepada Departemen", "Buku buku teks pelajaran yang telah dialihkan hak ciptanya kepada", "Bu tezin tasarımı hazırlanması yurutulmesi arastırmalarının yapılması ve bulgularının analizlerinde", "Bu tezin projenin Eskisehir Osmangazi Universitesi Bilimsel Arastırma ve Yayın", "Bu tezin kendi calısmam oldugunu planlanmasından yazımına kadar hicbir asamasında", "Bu tezin bana ait oldugunu tum asamalarında", "Bu tezin Eskisehir Osmangazi Universitesi Bilimsel Arastırma ve Yayın Etigi", "Bu tezde gorsel isitsel ve yazılı bicimde sunulan", "Bu tez calısmasının kendi calısmam oldugunu tezin", "Bu tez calısmasının kendi calısmam oldugunu", "Bu linki kullanabilmek icin bir sorumlu kodu ile nic tr", "Bu kitabın basım yayın ve satıs hakları", "Bu kitabın basım yayım ve satıs hakları Pegem Akademi Yay", "Bu kaydın yasal hukumlere uygun olmadıgını dusunuyorsanız lutfen sayfa sonundaki", "Bu dergide yayımlanan makalelerin telif hakkı", "Brought to you by The British Library Envia is a", "Brought to you by Google Scholar", "Brought to you by CORE", "Brill’s MyBook program is exclusively available", "Brill s MyBook program is exclusively available on BrillOnline Books", "Brief quotations from this thesis are allowable", "Brief quotations from this dissertation are allowable without special permission", "Brief portions of material in this publication may be copied", "Brief Reports should be submitted online", "Brazilian Army soldiers take part in a simulation of decontamination", "Both individuals and organizations that work with arXivLabs have", "Both first and second authors shared equally", "Both first and second authors have contributed in equal amounts", "Both first and second authors have contributed equally", "Both first and second authors equally contributed", "Both first and second authors contributed equally", "Both first and second author shared equally", "Both first and second author have contributed in equal amounts", "Both first and second author have contributed equally", "Both first and second author equally contributed", "Both first and second author contributed equally", "Book reviews express the opinions of the individual authors", "Book Reviews available at https www cambridge org core", "Bienvenue dans la version modernisee de Corpus UL", "Bienvenido al Portal de revistas", "Biblioteca de la Universidad Complutense Politicas de las editoriales", "Bibliographica es una publicacion semestral auspiciada por", "Bibliographic data the information relating to research outputs", "BiblioLife Reproduction Series Our goal at BiblioLife", "Beserta perangkat yang diperlukan bila ada Dengan Hak Bebas Royalti", "Beserta kelengkapan lain yang ada apabila diperlukan Dengan Hak Bebas", "Benar benar merupakan karya saya Saya tidak mengambil sebagian atau", "Benar benar merupakan karya ilmiah yang disusun sendiri bukan duplikat", "Benar benar hasil karya saya sendiri Pernyataan ide maupun kutipan", "Behoudens de in of krachtens de Auteurswet van gestelde", "Behoudens de in of krachtens de Auteurswet gestelde", "Beginning January the journals books and magazines", "Before you decide to submit your paper", "Before you continue you ll have to accept our cookies", "Before using high resolution images of Emory Law faculty members", "Before sending a manuscript to our journal we recommend", "Because our current commenting tool no longer satisfies the needs", "Based on traditional Jesuit thought the journal promotes", "Barangsiapa dengan sengaja melanggar dan tanpa hak melakukan perbuatan sebagaimana", "Barangsiapa dengan sengaja dan tanpa hak melakukan perbuatan sebagaimana dimaksud", "Barang siapa yang dengan sengaja menyiarkann memamerkan mengedarkan atau menjual", "Barang siapa yang dengan sengaja menyiarkan memamerkan mengedarkan atau menjual", "Barang siapa dengan sengaja menyiarkan memamerkan mengedarkan atau menjual kepada", "Barang siapa dengan sengaja melanggar dan tanpa hak melakukan perbuatan", "Barang siapa dengan sengaja dan tanpa hak melakukan perbuatan sebagaimana", "Ball State University LibrariesLibrary services and resources for knowledge buildingMasters", "Bajo la licencia Atribucion No Comercial CC BY", "Bajo esta licencia se permite cualquier explotacion de la obra", "Bai giang du thi thiet ke bai giang đien tu", "Bagian atau keseluruhan isi Tugas Akhir ini tidak pernah diajukan", "Background Open Access Review", "Background Open Access Feature", "Background Open Access Editorial", "Background Open Access Article", "Background CHE Discussion Papers DPs began publication", "Bachelor Thesis from the year in the subject", "Bacalah pernyataan pernyataan pada lembar berikut", "BIOTIKA Jurnal Ilmiah Biologi adalah Jurnal terbuka yang diterbitkan", "BIOTECNIA Vol", "BIOETICA COVID CULTURA EDUCACION MEDICA EDUCACION PROFESIONAL ENSENANZA EPIDEMIOLOGIA aprendizaje", "B66C Подъемные краны основным элементом которых является балка укосина или", "B66C Грузозахватные элементы или устроиства конструктивно сопряженные или приспособленные для", "B65G Устроиства для манипулирования изделиями или материалами конструктивно сопряженные с", "B65G Транспортировка сыпучих материалов по желобам трубопроводам или трубам с", "B65G Конвеиеры с бесконечными тяговыми элементами например цепями передающими движение", "B65G Конвеиеры с бесконечными грузонесущими поверхностями например лентами и т", "B30B Прессы для прессования изделии из материалов в пластичном состоянии", "B28C Способы и устроиства для получения смесеи цемента с другими", "B26D Способы резки обрабатываемых изделии характеризуемые типом режущего элемента устроиства", "B24B Станки или устроиства для шлифования поверхностеи вращения обрабатываемых изделии", "B23Q Устроиства для манипулирования обрабатываемыми изделиями", "B23P Устроиства для сборки или разборки металлических узлов или металлических", "B23K Присадочные прутки электроды материалы или среды применяемые при паике", "B23B Токарные станки или устроиства приспособленные для обработки специальных", "B21D Подающие устанавливающие или накопительные устроиства конструктивно сопряженные или приспособленные", "B21B Устроиства для перемещения поддерживания и фиксирования проката или для", "B07C Сортировка по параметрам или своиствам сортируемых изделии или материалов", "B05B Распылители для жидкостеи или других текучих веществ из двух", "B05B Насадки распылительные головки и т п со вспомогательными устроиствами", "B01J Способы или устроиства для гранулирования материалов вообще воздеиствие на", "B d pod czenia do bazy Unknown database ksiegarnia B", "Avtorji sprejetih prispevkov ohranijo avtorske pravice", "Avisos de derechos de autor propuestos por Creative Commons", "Avis legal La© Generalitat de Catalunya permet", "Avis legal La Generalitat de Catalunya permet la reutilitzacio dels", "Avis legal D acord amb l article", "Avis dd ’interruption de service", "Avis d interruption de service du", "Avertissement en matiere de Droits d auteur", "Avec l autorisation formelle de la directrice de la revue", "Avant de proceder a toute mise en ligne", "Available online on", "Available from UMI in association with", "Available Online September", "Available Online Sep", "Available Online October", "Available Online Oct", "Available Online November", "Available Online Nov", "Available Online May", "Available Online March", "Available Online Mar", "Available Online June", "Available Online Jun", "Available Online July", "Available Online Jul", "Available Online January", "Available Online Jan", "Available Online February", "Available Online Feb", "Available Online December", "Available Online Dec", "Available Online August", "Available Online Aug", "Available Online April", "Available Online Apr", "Availability This digital text is publicly available", "Autorzy tekstow przyjetych do publikacji w czasopismie", "Autorizo la inclusion del articulo de mi autoria adjuntado en", "Autorizo amos que la obra sea puesta a disposicion del", "Autorizo amos a la Biblioteca Octavio Arizmendi Posada de la", "Autorizo a publicacao do artigo submetido a esta revista Estou", "Autorizo a la Universidad de Cordoba para publicar tesis trabajo", "Autorizo a la Escuela Colombiana de Ingenieria Julio Garavito para", "Autorizo a la Biblioteca de la Facultad", "Autoriu teises ©", "Autorisation formelle accordee le septembre par", "Autorisation formelle accordee le peut par", "Autorisation formelle accordee le octobre par", "Autorisation formelle accordee le novembre par", "Autorisation formelle accordee le mars par", "Autorisation formelle accordee le juin par", "Autorisation formelle accordee le janvier par", "Autorisation formelle accordee le fevrier par", "Autorisation formelle accordee le decembre par", "Autorisation formelle accordee le avril par", "Autorisation formelle accordee le aout par", "Autori zadrzavaju autorska prava", "Autores tem permissao e sao estimulados a publicar e distribuir", "Autores tem permissao e sao estimulados a publicar", "Autores tem permissao de distribuir seu trabalho online ex em", "Autores tem autorizacao para assumir contratos", "Autores que publicam nesta revista concordam com os seguintes termos", "Autores mantem os direitos autorais", "Autores do manuscrito deverao preencher e assinar a Declaracao de", "Autorenhinweise und Angaben zur formalen Gestaltung der Manuskripte konnen im", "Autoras es mantem os direitos autorais", "Autoras e autores mantem os direitos autorais e concedem a", "Autor zgłaszajac tekst do redakcji czasopisma", "Autor zachowuje prawa majatkowe ale udziela zgody", "Autor zachowuje autorskie prawa majatkowe do utworu", "Autor oswiadcza ze przysługuja mu osobiste i majatkowe prawa autorskie", "Autor musi wypełnic i przesłac na adres redakcji", "Autor ma mozliwosc udzielania zgody niewyłacznej", "Authors who publish with this journal", "Authors warrant that their submission is their own original work", "Authors retain their rights", "Authors retain their right", "Authors retain their copyrights", "Authors retain their copyright", "Authors retain their copy rights", "Authors retain their copy right", "Authors retain the copyright on their work", "Authors retain copyright in the", "Authors retain copyright and grant", "Authors of manuscripts accepted for publication", "Authors of articles appearing in the journal are solely responsible", "Authors must complete sign and submit the Review and Publication", "Authors mantain the rights to their work", "Authors maintain the right to their work", "Authors keep their rights", "Authors keep their right", "Authors keep their copyrights", "Authors keep their copyright", "Authors keep their copy rights", "Authors keep their copy right", "Authors have full copyright and property rights to their work", "Authors guarantee the journal the right", "Authors grant the right of first publication", "Authors can separately make additional contractual arrangements", "Authors are permitted and encouraged to", "Authors are confirming that they are the authors", "Authors are authorised to enter into additional contracts", "Authors are able to enter separate additional contractual agreements", "Authors are able to enter into separate additional", "Authorization to photocopy items for", "Author s retain their rights", "Author s retain their right", "Author s retain their copyrights", "Author s retain their copyright", "Author s retain their copy rights", "Author s retain their copy right", "Author s retain the copyright on their work", "Author s retain copyright in the", "Author s retain copyright and grant", "Author s of this paper may load this reprint on", "Author s maintain the rights to their work", "Author s maintain the right to their work", "Author s hereby grant to Publisher all right title", "Author s grant the Alberta Law Review", "Author manuscripts deposited to comply with open access", "Author inquiries For inquiries relating to the submission of articles", "Author abstracts and or summaries are added to all descriptions", "Author Posting ©", "Author Note The first and second authors shared equally", "Author Note The first and second authors have contributed in equal amounts", "Author Note The first and second authors have contributed equally", "Author Note The first and second authors equally contributed", "Author Note The first and second authors contributed equally", "Author Note The first and second author shared equally", "Author Note The first and second author have contributed in equal amounts", "Author Note The first and second author have contributed equally", "Author Note The first and second author equally contributed", "Author Note The first and second author contributed equally", "Author Correction Report from a multidisciplinary meeting", "Auteurs behouden de auteursrechten", "Aujourd hui compose de titres notre portefeuille de revues", "Audiovisual material available from this site", "Audio files are EID restricted Individuals without an EID should", "Audio captured using a TEAC A reel to reel player", "Audio captured using a Nakamichi MR head professional cassette deck", "Auckland University of Technology AUT encourages public access", "Attribution You must give appropriate credit provide a link to", "Attribution You must attribute the work in the manner", "Attribution NonCommercial ShareAlike", "Attribution Non Commercial Share", "Attribution International CC BY", "Attention is drawn to the fact that copyright of this", "Atribuicao CC BY Este e um artigo de acesso aberto", "Atribuicao BY Os licenciados tem o direito de copiar", "Atribucion debe dar credito de manera adecuada brindar un enlace", "Atribucion Usted debe dar el credito apropiado proporcionar un enlace", "Atribucion Usted debe dar credito de manera adecuada", "Atribucion NoComercial SinDerivadas", "Atribucion NoComercial Internacional Esta obra esta bajo licencia internacional", "Atribucion No Comercial Compartir igual esta licencia permite a otros", "Atlantis Press now part of Springer Nature", "Atlantis Press is a professional publisher of scientific", "Atencion Secundaria de Salud COVID SARS CoV adolescente adulto adulto", "Atas rahmat Allah SWT akhirnya Penulis dapat menyelesaikan", "Astrolabio Nueva Epoca esta protegida bajo licencia", "Assertions and opinions in this publication are solely those of", "Asimismo cedo a la Universidad Nacional de Chimborazo en forma", "Asi mismo declaro tambien bajo juramento que todos los datos", "As this article doesn t contain an abstract", "As the designated authority under the National Historic Preservation Act", "As the access to this document is restricted", "As such copyright for articles published in this journal is", "As part of Open Journals initiatives we create website", "As opinioes emitidas nesta publicacao sao", "As of academic evergreen edu will no", "As manifestacoes expressas por integrantes dos quadros da Fundacao", "As a service to our customers we are providing this", "As a learned society the EPS engages in activities that", "As a BERA member you will receive access to the", "Artikelmanuskripte werden elektronisch per", "Artikel yang diterbitkan oleh JSI menggunakan lisensi Creative Commons Attribution", "Artikel Jurnal Manajemen STIE Muhammadiyah", "Articulo publicado por la Revista", "Articulo Modalidades de trabajo y tesis de grado dentro", "Articulo La seguridad social tiene por finalidad garantizar el", "Articulo 3o El trabajo es un derecho y un deber", "Articulo 1o Esta Ley regula la educacion que imparten el", "Articles submitted to the journal should not have been published", "Articles published in MPI are licensed", "Articles published electronically ahead of print and our archive from", "Articles in press are peer reviewed accepted articles to be", "Articles and reviews in AHMR reflect the opinions of", "Article usage statistics combine cumulative total PDF downloads and full", "Article published in the July issue of Anuseelan Research Journal", "Article published in the January issue of Anuseelan Research Journal", "Article history Received", "Article abstract not included", "Article Views are the COUNTER compliant", "Article Submissions We welcome articles of interest to", "Article Info History Articles Received", "Are you an author who wants to keep tabs on when your article", "Archives of Metallurgy and Materials is a quarterly journal", "Archivaria the journal of the Association of Canadian Archivists ACA is", "ArcheoArte e una rivista scientifica internazionale", "Aqui podras encontrar cerca de publicaciones producidas", "Aqui podra consultar mas de mil publicaciones digitales", "Aquesta web usa cookies", "Aquesta pagina web utilitza cookies", "Aquesta obra ha estat publicada com a part del projecte", "Aquesta obra esta subjecta a una llicencia", "Aquesta obra es subjecta llevat que s indiqui el contrari", "Aquest material esta subjecte a una llicencia", "Aquest document esta subjecte a una llicencia", "Apuntes revista de Ciencia Sociales publica todos sus articulos", "Applied Multivariate Research is a peer reviewed journal", "Apart from any fair dealing for the purposes of research", "Ao submeter uma obra a apreciacao e possivel publicacao na", "Ao publicar en Innovacion Educativa o autor cede", "Ao publicar en Estudos de Linguistica Galega o autor cede", "Ao publicar en Elos o autor cede", "Ao publicar en BGL o autor cede", "Anyone is allowed to distribute remix tweak", "Anyone can freely access the full text of works made available as Open Access", "Any use you make of these documents or images", "Any statements expressed in these materials are those of the individual authors", "Any staff member including those holding honorary status", "Any redistribution or reproduction of part or all of the", "Any party may pass on this Work by electronic means", "Any opinions expressed in this paper are", "Any item and its associated metadata held in the University", "Any copies made from materials held by IUPUI University Library's", "Antropologia Architettura design arte territorio Comunicazione", "Annual meeting with ordinary general meeting of the DPG", "Anemia Bibliometria COVID COVID estudio predictivo curvas de crecimiento", "Andel som har vært ganske mye plaget eller veldig mye", "Andamios Revista de Investigacion Social es una publicacion de caracter", "Anales del Instituto de Estudios Madrilenos publica", "An internship is a student planned and faculty supervised experience", "An independent study course is an opportunity to do independent", "An dieser Stelle finden Sie die von uns angebotenen Diplom", "An annual report presenting the activities of the Consortium CASPUR", "An amendment to this paper has been published", "An abstract is not available for this content", "An OpenURL link contains article metadata and directs it to the OpenURL server", "An Independent Study provides an opportunity to work independently", "Amt fur Statistik Berlin Brandenburg weniger als die Halfte von Behlertstraße", "American Physiological Society ISSN", "American Federation for Medical Research Published by the BMJ", "Ambitos Revista internacional de Comunicacion es una revista", "Amac Tarım Orman Su urunleri ve Veterinelik alanında yazılan makaleler", "Although this file was scanned from the highest quality microfilm", "Although all care is taken to ensure integrity and the", "Alternatively you can download the file directly", "Alternatively you can download the PDF file", "Alternatively you can also download the PDF file", "Alternativ konnen Sie die PDF Datei direkt auf Ihren Computer laden", "Already a subscriber Access the article", "Allen Press plays a vital role in the dissemination", "Alle rechten voorbehouden Niets uit deze uitgave mag worden verveelvoudigd", "All use subject to http about jstor org", "All unpublished materials are protected by copyright", "All the work in this journal are licensed", "All the content made available in this website represent exclusively the opinion of their", "All the authors must follow the current requirements for publication", "All text and images on this website and the content of all electronic files", "All rights reserved", "All rights reserve No part of this publication may", "All rights are reserved", "All published works remain the copyright of the author", "All proprietary rights other than copyright", "All physical materials associated with the New England Province Archive", "All payments are by credit card", "All papers published in this", "All outputs in CLoK are protected by Intellectual Property Rights", "All of the resources listed on this page are available", "All of our books are in the public domain", "All of our books arc in the public domain", "All of CGSpace Communities & Collections", "All material supplied via TamPub is protected by copyright", "All material supplied via HELDA is protected by copyright", "All material supplied via Goldsmiths Library and Goldsmiths Research Online", "All material supplied via Aaltodoc is", "All material submitted for publication is assumed to be submitted", "All material on this site has been provided by", "All material contained within the thesis including", "All material contained in this site is protected by law", "All material contained in Atlantic Geology is copyrighted", "All journal content made available on UTP Journals", "All intellectual property in relation to material included", "All images and materials are for viewing purposes", "All editoral decisions were made by", "All digitized texts and images in the AUB Libraries", "All course materials including lecture notes and other additional materials", "All copyright electronic and other of the text", "All content in this Collection is owned by", "All content in PEARL is protected by copyright", "All claims expressed in this article are", "All articles published in Education in the North are licensed", "All articles are published under the CC BY", "All articles accepted for publication", "All UHM dissertations and theses are protected by copyright", "All Treesearch publications were written or produced by Forest Service personnel", "All NAAC accredited institutions will submit an annual self reviewed", "Al someter una colaboracion a la consideracion de la", "Al ser una autorizacion a titulo gratuito no reclamare ni", "Al publicar en Verba el autor cede", "Al publicar en RIPS el autor cede", "Al publicar en RELAdEI el autor cede", "Al publicar en Quintana el autor cede", "Al publicar en Ohm el autor cede todos", "Al publicar en Moenia el autor cede", "Al publicar en Dereito el autor cede", "Al publicar en Agora el autor cede", "Al proponer un trabajo para su publicacion los autores", "Al presentar esta tesis como uno de los requisitos previos", "Al momento de enviar sus contribuciones los colaboradores deberan declarar", "Al momento de enviar sus contribuciones los colaboradores deberan autorizar", "Al fine di ottimizzare la procedura di pubblicazione degli atti in Gazzetta", "Al enviar los articulos para su publicacion el los autores", "Al enviar los articulos para su evaluacion los autores", "Al enviar los articulos para su evaluacion el los autor", "Al enviar los articulos para evaluacion los autores", "Al enviar el articulo para su evaluacion y posterior publicacion", "Al aceptarse los envios para la publicacion en", "Akademisyen ve arastırmacılara ise makalelerini en kolay ve hızlı bicimde", "Airlangga Law Library adalah salah satu unit bagian akademik Fakultas", "Aim The main aim of the working paper series of", "Agricultural Law Digest is published by the Agricultural Law Press", "After careful and considered review of the content of this paper", "Affilliation _Sem informacao completar", "Advertisements on this site do not constitute", "Adicionalmente declaro conocer y aceptar la disposicion", "Adhere to UFC Unified Facilities Guide Specifications", "Additional fields Licencja", "Adapun bagian bagian tertentu dalam penulisan skripsi yang saya kutip", "Adalah benar hasil karya saya dan penuh kesadaran", "Adalah benar disusun dibuat oleh saya sendiri", "Adalah benar benar Hasil Karya Ilmiah Tulisan Saya Sendiri", "Acık Bilim Sanat Arsivi Mimar Sinan Guzel Sanatlar Universitesi tarafından", "Actes du colloque organise par l Ecole", "Actes de conference partie contribution originale a la litterature", "Acte d investidura del professor", "Actas del Volumen de las", "Acta Zoologica Mexicana nueva serie es una publicacion", "Acta Universitatis Lodziensis Folia Geographica", "Acta Universitatis Carolinae Iuridica AUC Iuridica is", "Acta Universitaria volumen es una revista anual", "Acta Crystallographica Section E Structure Reports Online is the IUCr", "Acta Academica es un proyecto academico sin fines de lucro", "Acknowledgement of Country RMIT University acknowledges", "Acerca del Repositorio Politica del Repositorio", "Acerca del COVID Acerca del Repositorio Politica", "According to the Paperwork Reduction Act", "Accessing digital books and borrowing paper books are free", "Accessibility If you are unable to use this file", "Accessibility If you are experiencing difficulty with accessibility", "Access to this collection is unrestricted", "Access to these recordings is restricted to Chapman", "Access to the website is gained by", "Access to the thesis is subject to the Creative Commons", "Access to the electronic edition", "Access to paid content on this site is currently suspended", "Access provided at Sep", "Access provided at Oct", "Access provided at Nov", "Access provided at May", "Access provided at Mar", "Access provided at Jun", "Access provided at Jul", "Access provided at Jan", "Access provided at Feb", "Access provided at Dec", "Access provided at Aug", "Access provided at Apr", "Access note Some of the items in this collection are", "Access limited to current ORU", "Access is restricted to Touro College", "Access file scanned at dpi Black and White", "Access Rights To access this item", "Access Level This thesis was digitised for the purposes of", "Access JDreamⅢ for advanced search and analysis", "Accepted manuscripts are PDF versions", "Accepted Manuscripts are published online", "Accepted Manuscript is the version of the article", "Accediu a l espai El meu RACO per veure a", "Acceder au site de la Bibliotheque nationale de France Nouvelle", "Academic librarians may work one on one with students", "Abstrak Abstrak ditulis dalam bahasa Indonesia antara kata dan berisi", "Abstracts for Oral Sessions Colloquia and Workshops are grouped by", "Abstracts and presentations are embargoed", "Abstracts EAU19 & x2013", "Abstract 서울 특별시 용산구 한강로 가 용성 비즈텔", "Abstract この 論文 は 国立 情報 学 研究 所 の", "Abstract ملزومات سیستم ویندوز با پشتیبانی متون عربی IE6شیوه", "Abstract ملزومات سیستم Windows Media Player و شيوه دسترسي شبکه", "Abstract ملزومات سيستم ويندوز با پشتيباني متون عربي IE6", "Abstract ملاحظات ملزومات نظام ويندوز با پشتيباني متون ملاحظات", "Abstract ملاحظات ملزومات نظام ويندوز با پشتيباني متون عربي", "Abstract ملاحظات ملزومات سيستم ويندوز ۹۸ با پشتيباني متون عربي", "Abstract МІНІСТЕРСТВО ОСВІТИ І НАУКИ УКРАІНИ МІНІСТЕРСТВО ОСВІТИ І НАУКИ", "Abstract © This manuscript version is made available", "Abstract © The Authors", "Abstract © The Author", "Abstract © IEEE", "Abstract unavailable for this article", "Abstract none Keywords none Browse", "Abstract max 250 words", "Abstract jress dvi A classification based approach to monitoring the", "Abstract in2p3 v1 Measurement of the neutrino velocity with", "Abstract he Policy Research Working Paper Series disseminates", "Abstract econstor www econstor eu Der Open Access Publikationsserver", "Abstract does not appear First page follows", "Abstract currently not available", "Abstract coverelments pdf find your future the young person's guide", "Abstract cmyk CMAJ September", "Abstract cmyk CMAJ October", "Abstract cmyk CMAJ November", "Abstract cmyk CMAJ May", "Abstract cmyk CMAJ March", "Abstract cmyk CMAJ June", "Abstract cmyk CMAJ July", "Abstract cmyk CMAJ January", "Abstract cmyk CMAJ February", "Abstract cmyk CMAJ December", "Abstract cmyk CMAJ August", "Abstract cmyk CMAJ April", "Abstract br ملزومات سیستم Windows Media Player و شيوه دسترسي", "Abstract available from the A HREF", "Abstract Your use of the JSTOR archive indicates", "Abstract Where Futures Are Built Postgraduate Prospectus", "Abstract Unlike some other reproductions of classic texts We have not used OCR", "Abstract University pathways AcAdemic And english lAnguAge courses for internAtionAl", "Abstract URI http", "Abstract To read this article in full you may need to log", "Abstract This thesis or dissertation is not available", "Abstract This publication contains reprint", "Abstract This is the author s version of a work", "Abstract This is an accepted article with a DOI", "Abstract This is an Open Access article", "Abstract This is a title only record which contains no abstract", "Abstract This is a compilation of research trial reports from", "Abstract This document was submitted to the IETF", "Abstract This document is the published result", "Abstract This document is part of the Supplement containing the", "Abstract This document is part of Volume", "Abstract This document is part of Subvolume", "Abstract This document is part of Part of Subvolume", "Abstract This document is part of Part Organic Metalloid", "Abstract This document is part of Part Aliphatic Compounds", "Abstract This document is one entry in a series of", "Abstract This collection of over black and white photographs", "Abstract This article was submitted without an abstract", "Abstract This article is open access", "Abstract This Working Paper should not be reported as", "Abstract This PDF file contains", "Abstract This Chart is available in the print version", "Abstract These instructions give you basic guidelines for preparing camera", "Abstract These abstracts are taken from", "Abstract There is no abstract available", "Abstract The views expressed in this Working Paper are those", "Abstract The online version of this article along with updated", "Abstract The Policy Research Working Paper Series disseminates", "Abstract The Impact Evaluation Series has been established in recognition", "Abstract The Catherwood Library and ILR School at Cornell are pleased to", "Abstract Tesis doctoral inedita leida en la Universidad Autonoma de", "Abstract Technical Report No Research Laboratories", "Abstract THE AZ OF DEGREES To see a full list", "Abstract Submitted to the International Conference", "Abstract Submitted to the Faculty of Graduate Studies and Research", "Abstract Submitted to the 9th Annual", "Abstract Submitted to the 8th Annual", "Abstract Submitted to the 7th Annual", "Abstract Submitted to the 6th Annual", "Abstract Submitted to the 5th Annual", "Abstract Submitted to the 4th Annual", "Abstract Submitted to the 3rd Annual", "Abstract Submitted to the 2nd Annual", "Abstract Submitted to the 20th Annual", "Abstract Submitted to the 1st Annual", "Abstract Submitted to the 19th Annual", "Abstract Submitted to the 18th Annual", "Abstract Submitted to the 17th Annual", "Abstract Submitted to the 16th Annual", "Abstract Submitted to the 15th Annual", "Abstract Submitted to the 14th Annual", "Abstract Submitted to the 13th Annual", "Abstract Submitted to the 12th Annual", "Abstract Submitted to the 11th Annual", "Abstract Submitted to the 10th Annual", "Abstract Source of Description This bibliographic record is available under", "Abstract Search by Subject Search using", "Abstract Science & Technology Technology Physical Sciences Instruments & Instrumentation", "Abstract Science & Technology Physical Sciences Astronomy", "Abstract Science & Technology Life Sciences & Biomedicine", "Abstract School of Education Open Learning Spring Programme", "Abstract SPringerforR_DT_C_insideTheAmericasNew SPRINGER VERLAG NEW YORK LLC SPRINGER FOR R&D", "Abstract Revised june Facts and recommendations in this publication may", "Abstract Revised September Facts and recommendations in this publication may", "Abstract Revised October Facts and recommendations in this publication may", "Abstract Revised Novembrr Facts and recommendations in this publication may", "Abstract Revised May Facts and recommendations in this publication may", "Abstract Revised March Facts and recommendations in this publication may", "Abstract Revised July Facts and recommendations in this publication may", "Abstract Revised January Facts and recommendations in this publication may", "Abstract Revised February Facts and recommendations in this publication may", "Abstract Revised December Facts and recommendations in this publication may", "Abstract Revised August Facts and recommendations in this publication may", "Abstract Revised April Facts and recommendations in this publication may", "Abstract Research Report No Research Laboratories of", "Abstract Reprinted September Facts and recommendations in this publication may", "Abstract Reprinted October Facts and recommendations in this publication may", "Abstract Reprinted November Facts and recommendations in this publication may", "Abstract Reprinted May Facts and recommendations in this publication may", "Abstract Reprinted March Facts and recommendations in this publication may", "Abstract Reprinted June Facts and recommendations in this publication may", "Abstract Reprinted July Facts and recommendations in this publication may", "Abstract Reprinted January Facts and recommendations in this publication may", "Abstract Reprinted February Facts and recommendations in this publication may", "Abstract Reprinted December Facts and recommendations in this publication may", "Abstract Reprinted August Facts and recommendations in this publication may", "Abstract Reprinted April Facts and recommendations in this publication may", "Abstract Refbacks There are currently no refbacks", "Abstract Radio Resource Management Framework for System Level Simulations in", "Abstract Queen's University Belfast Undergraduate Prospectus", "Abstract Published September Facts and recommendations in this publication may", "Abstract Published October Facts and recommendations in this publication may", "Abstract Published November Facts and recommendations in this publication may", "Abstract Published May Facts and recommendations in this publication may", "Abstract Published March Facts and recommendations in this publication may", "Abstract Published June Facts and recommendations in this publication may", "Abstract Published July Facts and recommendations in this publication may", "Abstract Published January Please look for up to date information", "Abstract Published January Facts and recommendations in this publication may", "Abstract Published February Facts and recommendations in this publication may", "Abstract Published December Facts and recommendations in this publication may", "Abstract Published August Facts and recommendations in this publication may", "Abstract Published April Facts and recommendations in this publication may", "Abstract Publications mainResearchArea", "Abstract Publications journalExtension volume", "Abstract Publications journalExtension journalNumber", "Abstract Proceedings from the Fifth Annual", "Abstract PowerPoint presentatie TREE RING ARCHIVE OF HISTORICAL WOOD SAMPLES", "Abstract Please provide alternate text for this image to ensure", "Abstract Pendekatan strategi metode dan teknik pembelajaran pada hakikatnya dapat", "Abstract POOL II ED AS NG We are what we", "Abstract Open Learning SHORT PART TIME COURSES Autumn New Year", "Abstract Notice Undefined index", "Abstract Notes 論説 Genre Journal Article", "Abstract Notes The Research Genre Article URL", "Abstract Notes The Interview Genre Article URL", "Abstract Notes Summaries of Doctoral Theses", "Abstract Notes Summaries of Doctor and Master Theses", "Abstract Notes Genre Technical Report URL", "Abstract Notes Genre Research Paper URL", "Abstract Notes Genre Journal Article URL", "Abstract Notes Genre Departmental Bulletin Paper", "Abstract Notes Genre Book URL", "Abstract Notes Genre Article URL", "Abstract No Abstract Available", "Abstract NimbusSanL Bold Zurich Open Repository", "Abstract Nig J Anim Prod", "Abstract NOTE The first page of text has been automatically extracted", "Abstract NOTE Text or symbols not renderable in plain ASCII", "Abstract NCBI GEO standards and services for microarray data", "Abstract My NCBI Help My NCBI saves", "Abstract Microsoft Word VITAE_D_TRUST__13 docx A Fuzzy Approach to Trust", "Abstract Microsoft Word IJART0702 JENSEN_no downsample doc Int J", "Abstract Microsoft Word AfricaNewJournal_Revised docx Starting a New Scholarly Journal", "Abstract MINISTERE DE L ENSEIGNEMENT SUPERIEUR ET DE LA RECHERCHE", "Abstract Ludwig Eichinger and Francisco Rivero eds Dictyostelium discoideum", "Abstract LET US TAKE YOU HIGHER INTERNATIONAL PROSPECTUS I chose", "Abstract Kartoteka wypisow zrodłowych dotyczacych osad na Mazowszu w okresie", "Abstract Journal of Nobel Medical College", "Abstract Journal of Molecular Biochemistry ©", "Abstract Journal de la Recherche Scientifique de l Universite de", "Abstract J de la Recherche Scientifique de l Universite de", "Abstract Issue Title This issue is", "Abstract Issue Title Selected Papers", "Abstract IpswIch Undergraduate programs For every possible advantage in", "Abstract Information about reprints can be found", "Abstract Images captured on digital camera at dpi and", "Abstract ISSUE Autumn Winter", "Abstract Human T cell leukemia virus type HTLV", "Abstract Honolulu PRiME ©", "Abstract HAL is a multi disciplinary open access archive for", "Abstract Genre Thesis or Dissertation URL http koara lib keio", "Abstract Genre Journal Article URL http koara lib keio ac", "Abstract Genre Departmental Bulletin Paper URL http koara lib keio", "Abstract Generic abstract See the D0Note for the real abstract", "Abstract From the Washington University Undergraduate Research Digest", "Abstract From the Washington University Senior Honors Thesis", "Abstract From the Washington University Office of Undergraduate Research Digest", "Abstract From Methods in Molecular Biology", "Abstract For more information about the Organization of Teratology Information", "Abstract For Abstract see ChemInform Abstract", "Abstract Facts and recommendations in this publication may no longer", "Abstract Extended abstract of a paper presented at", "Abstract Este producto forma parte de una serie de", "Abstract Draw your reader in with an engaging abstract", "Abstract Details show hide Language s Dates", "Abstract DR AF T 4TH INTERNATIONAL WORKSHOP ON COGNITIVE INFORMATION", "Abstract Copyright©", "Abstract Copyright ©", "Abstract Copyright Information All rights reserved", "Abstract Copelin Commercial Photographers recorded exteriors and", "Abstract Contact Us Shankar B Chavan", "Abstract ChemInform is a weekly Abstracting Service", "Abstract Chapter Androgen Action During Prostate Carcinogenesis Diping Wang", "Abstract CONTENTS FOR FURTHER INFORMATION", "Abstract Available from UMI in association with The British Library", "Abstract An amendment to this paper has been published", "Abstract An abstract is not available", "Abstract All papers published in this", "Abstract All in text references underlined in blue are linked", "Abstract Agricultural and Rural Finance Markets in Transition Proceedings of", "Abstract Advanced LVDC Electrical Power Architectures and Microgrids A Step", "Abstract Adaptive Non Local Means for Cost Aggregation in a", "Abstract Access to abstract restricted until", "Abstract Access to abstract permanently restricted to", "Abstract Abstract previews are not available", "Abstract Abstract of paper presented at", "Abstract Abstract not reproduced here", "Abstract Abstract headers are listed alphabetically", "Abstract ALL TPEL Reg docx Active", "Abstract A more recent revision exists", "Abstract A Distributed Control Strategy for Coordination of an Autonomous", "Abstract 070816database_rights short On the treatment of the sui generis", "About The Journal The Journal of Bombay Natural History Society", "About Linkoping University Electronic Press Linkoping University Electronic Press LiU", "Aboriginal and Torres Strait Islander material and information accessed on", "Aalleiter f eel ladder eel pass Aalteich m eel pond", "AVIS L auteur a accorde une licence non exclusive", "AVIS L auteur a accorde une licence non", "AVIS Certaines perturbations pourraient survenir dues a la poursuite de", "AUC Studia Territorialia is an open access journal", "AUC Philologica Acta Universitatis Carolinae Philologica", "ASUNTOS DE GENERO COMERCIO INTERNACIONAL E INTEGRACION DESARROLLO ECONOMICO", "ASEE holds the copyright", "ARTICULO DERECHO DE AUTOR Es el derecho que se ejerce", "AREA es una publicacion arbitrada", "ARCA e l archivio istituzionale", "AQa qu2 æi", "APS and CERN the host organization of SCOAP Sponsoring", "ANU Press is a globally recognised leader in open access", "ANTI DISCRIMINATION NOTICE It is illegal to discriminate against", "ANO La revista Antiguedad y Cristianismo es una revista cientifica", "ANDREWS UNIVERSITY SEMINARY STUDIES publishes papers and brief notes on", "ANCIANO APRENDIZAJE ATENCION PRIMARIA DE SALUD Anciano CALIDAD", "AMS American Mathematical Society the tri colored AMS logo", "AMCoR に 収録 され た 学術 論文 の ほとんど は", "AMCoR Asahikawa Medical University Collection and Research is a database", "AIR nasce in UNIMI nel con lo scopo di raccogliere", "AIM AP Accurate Income Measurement for the Assessment of Public", "ADVERTIMENT La consulta d aquesta tesi queda condicionada", "ADVERTIMENT L acces als continguts d aquesta tesi doctoral", "ADVERTENCIA La consulta de esta tesis queda condicionada a la aceptacion", "ADS Classic will be deprecated in May", "ADS Classic is now deprecated", "ADOLESCENTE ADULTO ADULTO", "ADN Academia de Ciencias de Cuba COVID", "ADDITIONAL MENU FOCUS AND SCOPE EDITORIAL TEAM", "ADDITIONAL MENU EDITORIAL TEAM REVIEWERS", "ADB does not guarantee the accuracy of the data included", "ACS Editors' Choice ACS Editors’", "ACS Editors Choice This is an open access article", "ACS AuthorChoice This is an open access article", "ACS AuthorChoice ACS AuthorChoice", "ACM Slovakia offers a forum for rapid dissemination of research", "ACL materials are Copyright", "ACCESS RESTRICTED TO THE UNIVERSITY OF", "ACA member or Archivaria subscriber to login", "ABSTRAK SEKOLAH TINGGI ILMU KESEHATAN STIK BINA HUSADA PALEMBANG PROGRAM", "ABSTRAK Kementerian Kesehatan RI Politeknik Kesehatan", "ABSTRAK KATA PENGANTAR i DAFTAR ISI iii DAFTAR TABEL v", "ABSTRAK ABSTRACT KATA PENGANTAR i DAFTAR ISI iii DAFTAR LAMPIRAN", "ABSTRACT This article is in Free Access", "ABSTRACT The Open Access version of this book", "ABSTRACT Tampereen ammattikorkeakoulu", "ABSTRACT Tampere University of Applied Sciences", "ABSTRACT TAMPERE UNIVERSITY OF TECHNOLOGY Master s Degree Programme in", "ABSTRACT Oulu University of Applied Sciences", "ABSTRACT OF DOCTORAL DISSERTATION HELSINKI UNIVERSITY", "ABSTRACT OF DOCTORAL DISSERTATION AALTO UNIVERSITY", "ABSTRACT No preview is available for this paper", "ABSTRACT NOT AVAILABLE", "ABSTRACT Lappeenranta University of Technology", "ABSTRACT Lappeenranta Lahti University of Technology", "ABSTRACT KEMI TORNIO UNIVERSITY OF", "ABSTRACT Journal of Ultrafine Grained and Nanostructured Materials", "ABSTRACT Humak University of Applied Sciences", "ABSTRACT First Published in Routledge", "ABSTRACT Continue on reverse if necessary and identify by", "ABSTRACT Click here and insert your abstract", "ABSTRACT BINA HUSADA COLLEGE OF HEALTH SCIENCE PUBLIC HEALTH STUDY", "ABSTRACT Abstract of thesis presented to", "ABC C DEFGHI I JKLMNO O PQRS S TU U", "AAAS login provides access", "AAAAAA AAAA AAAA", "A61F Фильтры имплантируемые в кровеносные сосуды протезы т е искусственные", "A01N Биоциды репелленты или аттрактанты или регуляторы роста растении содержащие", "A weboldalunk elso alkalommal torteno meglatogatasakor", "A university thesis is a work protected by the Copyright", "A traves de las aportaciones de selectos especialistas el presente", "A traves de la presente declaracion concedo a favor de", "A traves de la presente declaracion concedemos a favor de", "A traves de la presente declaracion afirmamos que el trabajo", "A traves de este medio entrego un CD o USB", "A traves de este documento autorizo", "A thesis submitted in partial fullfilment", "A thesis submitted in partial fullfillment", "A thesis submitted in partial fulfilment", "A thesis submitted in partial fulfillment", "A submitted manuscript is the version", "A submitted manuscript is the author’s version", "A submitted manuscript is the authors version", "A submitted manuscript is the author s version", "A submissao implica a cessao de direitos", "A submissao de originais para a Letras de Hoje", "A su vez declaro que el articulo enviado a Ciencias", "A revista Prociencias aceita trabalhos ineditos sobre temas relativos", "A revista Opiniaes nao exerce cobranca pelas contribuicoes recebidas", "A revista Ininga fundada em e uma publicacao", "A revista Cadernos do LEPAARQ do Laboratorio de Ensino", "A reproduction of material that is protected by copyright", "A repozitorium felhasznalo kozpontu szolgaltatasfejlesztese az", "A rapid response is a moderated but not peer reviewed", "A publicacao do artigo em Educacao e Pesquisa implica automaticamente", "A opcao Busca avancada utiliza os conectores da logica boleana", "A not for profit organization since BPA Worldwide is", "A menos que se indique lo contrario los items del", "A media monitoring service searches for every mention of NEJM", "A man walks past the European Commission headquarters on which", "A fim de atender ao que dispoe a Lei de", "A dissertation submitted in partial fullfilment", "A dissertation submitted in partial fullfillment", "A dissertation submitted in partial fulfilment", "A dissertation submitted in partial fulfillment", "A copy of this title is held at the Hive", "A compilation of kinetics data on gas phase chemical reactions", "A autoria nao deve constar no artigo arquivo nem outra", "A aprovacao dos textos para publicacao implica a cessao imediata", "A aprovacao dos manuscritos implica cessao imediata", "A Revista se reserva o direito de efetuar nos originais", "A Revista oferece acesso livre e imediato ao seu conteudo", "A Revista Reconcavos e uma publicacao gratuita produzida pelo Centro", "A Revista Praia Vermelha e uma publicacao semestral", "A Revista Inquietude e uma publicacao do corpo discente", "A Revista Ideias & Inovacao Latu Sensu oferece acesso livre", "A Revista Iberica do Direito RID e a revista juridica", "A Revista Encontros Universitario da UFC surge em resposta a", "A Revista Direito UNIFACS Debate Virtual estara sempre aberta", "A Revista Arquivos Medicos adota como padrao de atribuicao", "A RevInt tem como objetivo a divulgacao dos melhores trabalhos", "A Rev Cereus uma publicacao eletronica vinculada a Pro Reitoria", "A ReBACC foi implantada pelo Conselho Regional de Contabilidade do", "A REPRESENTACAO DA ELITE DE COPACABANA IPANEMA LEME CIL NO JORNAL BEIRA", "A Medium For E coli g L K2HPO4 KH2PO4", "A ISys Revista Brasileira de Sistemas de Informacao e uma publicacao cientifica da", "A Fundacao Universidade Federal de Mato Grosso do Sul", "A Faculdade de Ciencias e Tecnologia ea Universidade", "A Doctoral Thesis Submitted in partial fullfilment", "A Doctoral Thesis Submitted in partial fullfillment", "A Doctoral Thesis Submitted in partial fulfilment", "A Doctoral Thesis Submitted in partial fulfillment", "A Cadernos de Campo Revista de Ciencias Sociais faz parte", "A BDJur e um repositorio mantido pelo Superior Tribunal de Justica", "2021 年 5 月 19 日付図書館ウェブサイトお知らせ", "1ª Serie speciale Corte costituzionale pubblicata il mercoledi 2ª Serie", "199x 200x IEEE Personal use of this material is permitted", "199x 200x IEEE AGU L utilisation a titre personnel de", "&60 &91 if gte mso 9&93 &62 &60 xml&62 &60"] \ No newline at end of file diff --git a/pageindex/flash/data/dictionaries.json b/pageindex/flash/data/dictionaries.json new file mode 100644 index 000000000..00f8697b5 --- /dev/null +++ b/pageindex/flash/data/dictionaries.json @@ -0,0 +1 @@ +{"references":["창고운힌","창고운헌","창고문힌","창고문헌","창 고 운 힌","창 고 운 헌","창 고 문 힌","창 고 문 헌","참고운힌","참고운헌","참고문힌","참고문헌 references","참고문헌","참고문省","참고 문헌","참 고 운 힌","참 고 문 힌","참 고 문 헌","참 고 문 省","참 고 운헌","잡고운힌","잡고운헌","잡고문힌","잡고문헌","잡 고 운 힌","잡 고 운 헌","잡 고 문 힌","잡 고 문 헌","삼고운힌","삼고운힌","삼고운힌","삼고운헌","삼고운헌","삼고운헌","삼고문힌","삼고문힌","삼고문힌","삼고문헌","삼고문헌","삼고문헌","삼 고 운 힌","삼 고 운 힌","삼 고 운 힌","삼 고 운 헌","삼 고 운 헌","삼 고 문 힌","삼 고 문 힌","삼 고 문 힌","삼 고 문 헌","삼 고 문 헌","삼 고 문 헌","삼 고 운헌","문헌","문 헌","考者文拭","発表文献","注 引用文献","注 引 用 文 献","歩考文拭","文獻","文献一覧","文献 references","文献","文 献","引用文甫穴","引用文献一覧","引用文献","引用 文献","引用 参考文献","引用 参考 文献","引 用 文 献","和文文献","和 文 文 献","呑考文拭","參考文獻","參考文獻","參 考 文 獻","参者文献","参考資料一覧","参考資料","参考論文","参考文默","参考文贰","参考文虑尺","参考文獻","参考文獻","参考文献一覧","参考文献と WEB サイト","参考文献 参考文献 参考文献 参考文献","参考文献 references","参考文献 references","参考文献 references","参考文献","参考文献","参考文","参考 文献 references","参考 文献 references","参考 文献","参考 文献","参考 文 献","参考 引用文献","参考","参照文献","参照 参考文献","参文献","参 考 文 献","参 考 文 献","参 文献","参 文 献","主要文獻","主要参考文献","主要参考文献","主要 文獻","संदर्भसामग्रीसूची","संदर्भसामग्री सूची","संदर्भ","Գրականություն","ԳՐԱԿԱՆՈՒԹՅՈՒՆ","Գ ր ա կ ա ն ո ւ թ յ ո ւ ն","цитируемая литература","цитированная литература","фоидаланилган адабиетлар","фоидаланиладиган адабиетлар","упоминаемая литература","ссылки","список цитируемои литературы","список цитированных источников","список цитированнои литературы","список рекомендуемои литературы","список рекомендованнои литературы","список публикации","список процитированнои литературы","список опублікованих наукових праць за темою дисертаціі","список літератури","список литературы","список источников литературы","список источников и литературы","список источников","список используемои литературы","список использованных источников","список использованнои литературы","список дополнительнои литературы","список джерел","список використаних літературних джерел","список використаних джерел та літератури","список використаних джерел","референце","рекомендуемая литература","рекомендованная литература","посилання та примітки","посилання","перелік посилань","перелік літератури","перелік джерел посилань","перелік джерел посилання","перелік використаних джерел","література","литература и примечания","литература и источники","литература references","литература","лiтаратура i крынiцы","лiтаратура","крынiцы i лiтаратура","источники литературы","источники информации","источники и литература","используемая литература","использованные источники литературы","использованные источники и литература","использованные источники","использованная литература","дополнительная литература","до цього література","джерела інформаціі","джерела та література","джерела","використані джерела","використана література","бібліяграфія","бібліографія","бібліографічні посилання","бібліографічнии список","библиография","библиографическии список","библиографические ссылки","аннотированныи список литературы","адабиетлар","Цитирана литература","Список источников и литературы","Сноски References","Паидаланылған әдебиеттер","Иқтибослар Сноски References","Иқтибослар Сноски","Иқтибослар References","Използвана литература","Библиография към автореферата","Πηγες και βιβλιογραφια","ΒΙΒΛΙΟΓΡΑΦΙΑ","ΒΗΒΛΗΟΓΡΑΦΗΑ","œuvres citees","zitierte literatur","zitiert literatur","works cited","weiterfuhrende literatur und dokumente","weiterfuhrende literatur","weiterfuhrende literatur","weitere literatur","verzeichnis der zitierten literatur","verzeichnis der abgekurzt zitierten literatur","verwendete und weiterfuhrende literatur","verwendete literatur","travaux cites","this article references","the bibliography","textos consultados","supplemental references","suggested reading","spisok literatury","sources primaires","sources et bibliographie","sources consultees","sources citees","sources bibliographiques","some reference links may require a separate subscription","seznam zdroju","seznam pouzitych zdroju","selected references","schrifttum","riferimenti references","riferimenti bibliografici","riferimenti","refrences","refferences","referenzen","referenz","referenties","referentes bibliograficos","referenslista","referenslista","referenser","referenser","referencje","referencias y bibliografia","referencias utilizada","referencias references","referencias references","referencias references","referencias gerais","referencias e notas","referencias e bibliografia","referencias e bibliografia","referencias documentales","referencias das fontes citadas","referencias da dissertacao","referencias consultadas","referencias consultadas","referencias citadas","referencias citadas","referencias citada","referencias bibliograficas utilizadas","referencias bibliograficas gerais","referencias bibliograficas de la bibliografia recomendada","referencias bibliograficas","referencias bibliograficas","referencias bibliograficas","referencias bibliografica","referencias bibliografias","referencias bibliografia","referencias","referencias","referencial bibliografico","referenciais bibliograficos","referenciais","referencia das fontes citadas","referencia bibliografica","referencia bibliografica","referencia","references 참고문헌","references 参考文献","references 参考文献","references 参考 文献","references литература","references top","references references","references literatura","references kaynakca","references go to","references generales","references et notes","references et citations","references et bibliographie","references citees","references cited","references bibliographiques","references bibliographiques","references bibliographiques","references bibliographique","references bibliographies","references bibliographie","references bibliographie","references bibliografia","references and recommended readings top","references and recommended readings","references and recommended reading top","references and recommended reading","references and recommended reading","references and recomended readings top","references and recomended readings","references and recomended reading top","references and recomended reading","references and notes","references and links","references and footnotes","references","referencer","reference text and citations","reference s","reference s","reference lists","reference list","reference bibliographiques","reference bibliographique","reference bibliografia","reference","referenc s","referenc es","referen es","referen ces","refere nces","refere ces","referansları","referanser litteraturliste","referanser","referancias","refer nces","refer ences","refe rences","refe re nces","refe ences","ref rences","ref erences","re ferences","re erences","r ferences","r eferences","r efer ences","r efe re nces","r ef erences","r e f e r e n c e s","quellen und weiterfuhrende literatur","quellen und literaturverzeichnis","quellen und literatur","quelle","principais referencias bibliograficas","piœmiennictwo","pismiennictwo","ouvrages de reference","ouvrages consultes","ouvrages cites","ouvrages cite","outras referencias","other references","obras citadas","notes de bas de page","notes and references","notas e referencias bibliograficas","notas e referencias","notas e bibliografia","makalede kullanılan kaynakların listesini sunar","makalede kullanılan kaynakları listeler","makale referansları","makale referanslar","litteraturliste","litterature","litteratur","literatuurlijst","literatuur haalde","literatuur","literaturverzeichnis","literaturverweise","literaturquellen","literaturnotizen","literaturnachweise","literaturnachweis","literaturliste","literaturliste","literaturhinweise","literaturhinweis","literaturempfehlungen","literaturempfehlung","literature references","literature clted","literature cited bibliografia","literature cited","literature","literaturauswertung","literaturauswahl","literaturangaben","literaturangabe","literatura references","literatura i izvori","literatura consultada","literatura citada","literatura cientifica citada","literatura","literatura","literatura","literatur zur vertiefung","literatur zum weiterlesen","literatur verzeichnis","literatur und weiterfuhrende literatur","literatur und quellenverzeichnis","literatur und quellen","literatur und medien","literatur und links","literatur und internetquellen","literatur und anmerkungen","literatur references","literatur quellen","literatur listesi","literatur auswahl","literatur","literatur","liste les references bibliographiques citees dans l article","liste des ouvrages cites","liste de references bibliographiques","liste de references","liste bibliographique","listado de referencias","lista las referencias bibliograficas utilizadas en la investigacion","lista las referencias bibliograficas utilizadas en el estudio","lista las fuentes bibliograficas utilizadas en la investigacion","lista de referencias bibliograficas","lista de referencias","lista de referencia","lista as referencias bibliograficas utilizadas no trabalho","lista as referencias bibliograficas utilizadas no estudo","lista as referencias bibliograficas utilizadas no capitulo","lista as referencias bibliograficas utilizadas no artigo","lista as referencias bibliograficas utilizadas na pesquisa","list of references","lecturas y bibliografia","la bibliographie","kısaltmalar ve bibliyografya","kirjandus references","kirjandus","kirjallisuus","kaynaklar references","kaynaklar listesi","kaynaklar dizini","kaynaklar calısmada kullanılan kaynakları listeler","kaynaklar","kaynakca tezde kullanılan kaynakların listesini sunar","kaynakca tezde kullanılan kaynakları listeler","kaynakca references","kaynakca references","kaynak listesi","kaynak kisi listesi","kallforteckning","gorsel kaynakları","gorsel kaynaklar","gorsel kaynakcası","further reading","fuentes y bibliografia","fuentes utilizadas","fuentes referenciales","fuentes primariaslista las fuentes bibliograficas utilizadas en la investigacion","fuentes de documentacion","fuentes de consulta","fuentes consultadas","fuentes citadas","fuentes bibliograficas","foydalanilgan adabiyotlar","foydalaniladigan adabiyotlar","fontes e referencias bibliograficas","fontes e referencias","erganzende und vertiefende literatur","erganzende literatur","endnotes","empfohlene literatur","deginilen belgeler","dc relation reference","daftar rujukan","daftar pustaka","daftar kepustakaan","citations hide citations","citations","citas bibliograficas","calısmada kullanılan kaynakların listesini sunar","calısmada kullanılan kaynakları listeler","bibliyografya ve kısaltmalar","bibliography and references","bibliography","bibliographische referenzen","bibliographies","bibliographie succincte","bibliographie sommaire","bibliographie sitographie","bibliographie selective","bibliographie references","bibliographie generale","bibliographie et webographie","bibliographie et sources","bibliographie et sitographie","bibliographie et references","bibliographie consultee","bibliographie citee","bibliographie","bibliographie","bibliographical references","bibliographic references","bibliographia references","bibliographia","bibliografie","bibliografie","bibliografickych odkazov","bibliografickych citaci","bibliografias consultadas","bibliografias","bibliografia y referencias electronicas","bibliografia y lecturas","bibliografia y hemerografia","bibliografia y fuentes","bibliografia utilizada","bibliografia utilizada","bibliografia utilizada","bibliografia sugerida","bibliografia referenciada","bibliografia references","bibliografia references","bibliografia reference","bibliografia principal","bibliografia literature cited","bibliografia geral","bibliografia geral","bibliografia fundamental","bibliografia especifica","bibliografia e webgrafia","bibliografia e referencias","bibliografia e referencias","bibliografia e outras fontes de informacao","bibliografia e fontes","bibliografia di riferimento","bibliografia de referencia","bibliografia de referencia","bibliografia de consulta bibliography","bibliografia de apoio","bibliografia consultada","bibliografia citada","bibliografia bibliography","bibliografia articulos y documentos consultados","bibliografia","bibliografi","bgekurzt zitierte literatur","benutzte literatur","ausgewahlte literatur","arastırmada kullanılan kaynakların listesini sunar","arastırmada kullanılan kaynakları listeler","anmerkungen und literatur","adabiyotlar royxati","adabiyotlar ro yxati","adabiyotlar","a0000005222 literatur","Zoznam pouzitej literatury","Zoznam literatury pouzitej v praci","Zoznam bibliografickych odkazov","ZOZNAM CITOVANEJ LITERATURY","VIRI","VERWYSINGS","Tai lieu tham khao","Soupis bibliografickych citaci","Seznam pouzite literatury","Schriftenverzeichnis","SECILMIS BIBLIYOGRAFYA","Referinte","Referencies Mes informacio","Referencies","References for Further Studies","References Annexure I","RUJUKAN","REFERENSI","REFERENSFORTECKNING","REFERENCNI SEZNAM","REFERENCIES BIBLIOGRAFIQUES","REFERENCIAS EN ESTE ARTICULO","REFERENCIAS EN ESTE ART CULO","REFERANSLAR","Pouzite prameny a literatura","Pouzita literatura","POPIS LITERATURE","MGA SANGGUNIAN","Litteraturforteckning","Literaturas saraksts","Literatura ir saltiniai","LITERATuR LiSTESi","LITERATUUROPGAWE","LITERATUROS SARASAS","LITERATURA IN VIRI","LITERATURA","LAHTEET","LAHDELUETTELO","Koriscena literatura","Kasutatud kirjandus","KAYNAKCA","KAYNAKCA","Jegyzetek es irodalmi hivatkozasok","Izmantotas literaturas saraksts","Izmantota literatura","Irodalomjegyzek","Irodalom References","Irodalom","Irodalom","Hivatkozasok","Heimildir","Heimildaskra","Felhasznalt irodalom","Erreferentziak","Erreferentzia bibliografikoak","Bibliografija","Bibliografia consultada","Bibiliograpiya","BRONNELYS","BIBLIYOGRAFYA references","BIBLIYOGRAFYA Bibliography","BIBLIYOGRAFYA","Annotated references","AVOTU UN LITERATURAS SARAKSTS"],"section_keywords":["целью","цель иссле","резюме","результат","предмет","методика","материалы","материал и методика","заключени","вывод","введени","Цель","Результаты","Методы","Краткие итоги","Заключение","Выводы","Висновки","Введение","Аннотация","Аннотации","zwecke","zweck","zusammenfassungen und folgerungen","zusammenfassungen und folgerung","zusammenfassungen / folgerungen","zusammenfassungen / folgerung","zusammenfassung und folgerungen","zusammenfassung und folgerung","zusammenfassung / folgerungen","zusammenfassung / folgerung","zusammenfassung","zoonotic risk","zielstellungen","zielstellung","zielsetzungen","zielsetzung","zielgroßen","ziele und vorgehen","ziele und fragestellungen","ziele und fragestellung","ziele / vorgehen","ziele / fragestellungen","ziele / fragestellung","ziele","ziel und vorgehen","ziel und fragestellungen","ziel und fragestellung","ziel der studie","ziel der arbeit","ziel / vorgehen","ziel / fragestellungen","ziel / fragestellung","ziel","yorum","yontemler ve gerecler","yontemler ve gerec","yontemler / gerecler","yontemler / gerec","yontemler","yontem ve gerecler","yontem ve gerec","yontem / gerecler","yontem / gerec","yontem","years","wyniki i wniokski","wyniki i wnioksek","wyniki / wniokski","wyniki / wnioksek","wyniki","wynik i wniokski","wynik i wnioksek","wynik / wniokski","wynik / wnioksek","wynik","wstep i cel pracy","wstep i cel","wstep / cel pracy","wstep / cel","wstep","wstep","wstcp","wprowadzenie","wound healing","workshop outcomes","workshop","working hypothesis","working examples","work results","work methods","work method","wnioski","wniosek","wissensstand","wider implications of these findings","wider implications of the study","wider implications of the findings","wider implications of the finding","wider implications for the findings","wider implications","wider implication of the findings","wider implication of the finding","why this matters to us","why this matters to me","why the guideline was changed","why should an emergency physician be aware of this","where next","what will the reader gain","what we did","what this study adds to our knowledge","what this study adds to existing knowledge","what this study adds","what this paper adds","what the readers will gain","what the reader will gain","what question this study addressed","what is unknown","what is new and conclusions","what is new and conclusion","what is new / conclusions","what is new / conclusion","what is known and what this paper adds","what is known and conclusion","what is known already","what is known about the topic","what is known about the subject","what is known / what this paper adds","what is known / conclusion","what is known","what is already known","werkwijze","weiterbehandlung","we used","we found that","we conclude that","we conclude","warning","vysledky","vysledek","vraagstelling","vorstellungen","vorstellung","vorgehen","volunteers and methods","volunteers / methods","volunteers","visual overview","viewpoints and conclusions","viewpoints and conclusion","viewpoints / conclusions","viewpoints / conclusion","viewpoints","viewpoint and conclusions","viewpoint and conclusion","viewpoint / conclusions","viewpoint / conclusion","viewpoint","video abstract","veterinary data synthesis","venue","various indications","variavel","variaveis estudadas","variaveis analisadas","variaveis","variables studied","variables of principal interest","variables of interest","variables measured and analysis","variables measured / analysis","variables measured","variables included","variables estudiadas","variables de estudio","variables analyzed","variables","variable measured","var huvudfraga ar","values and validation","values and originality","values / validation","values / originality","values","value of the paper","value and originality","value / originality","value","valor","validity assessment","validity and coverage","validity / coverage","validity","validation","validating the recommendations","validating the hypothesis","vaka takdimi","vaka raporu","vaccines","vaccination recommendations","vaccination","utility","utilidad","useful websites","useful website","used methods","use in practice","urvalskriterier","urval","ursachen","ursache","uppsatsens undersokningsfraga ar","uppsatsens forskningsfraga ar","update methodology","update","untersuchungsziele","untersuchungsziel","untersuchungsmethoden","untersuchungsmethode","untersuchungen","untersuchung","unresolved questions","universo y muestras","universo y muestra","universo / muestras","universo / muestra","universo","unit of analysis","uniqueness","unique information provided","unique identifier","unidades de analisis","unidad de analisis","undersokt grupper och metoder","undersokt grupper och metod","undersokt grupper / metoder","undersokt grupper / metod","undersokt grupp och metoder","undersokt grupp och metod","undersokt grupp / metoder","undersokt grupp / metod","undersokt grup","undersokningen","undersokning","underlying mechanisms","unanswered questions","umsetzungen","umsetzung","umin clinical trials registry identifier","uittreksel","uitgangspunten","ubersicht","uberlick","uberblick","tło","types of study","types of studies reviewed","types of patients","types of participants","types of outcome measures","types of literature reviewed","types of interventions","types of intervention","type series","type of the study","type of study design","type of study and setting","type of study / setting","type of study","type of studies reviewed","type of review and search strategy","type of review / search strategy","type of review","type of patients","type of participants","type of participant","type of observation","type locality","tweetable abstract","tumors","tujuan penelitian","tujuan","trials registry","trials registration numbers","trials registration number","trials registration","trials","trialregno","trial status","trial selection","trial registry number","trial registry name","trial registry","trial registrations number","trial registrations","trial registration numbers","trial registration number isrctn","trial registration number","trial registration no","trial registration isrctn","trial registration information","trial registration identifier","trial registration id","trial registration clinicaltrialsgov identifier","trial registration actrn","trial registration","trial register number","trial register","trial reg no","trial procedures","trial number","trial identifier","trial identification","trial eligibility criteria","trial details","trial design and methods","trial design / methods","trial design","trial appraisal and synthesis methods","trial appraisal / synthesis methods","trial","trends","trend","treatments and prognosis","treatments and prognoses","treatments and prevention","treatments and outcomes","treatments and outcome","treatments / prognosis","treatments / prognoses","treatments / prevention","treatments / outcomes","treatments / outcome","treatments","treatment strategy","treatment selection and planning","treatment selection / planning","treatment schedule","treatment results","treatment regimens","treatment recommendations","treatment protocol","treatment plan","treatment outcome","treatment options","treatment innovations","treatment and results","treatment and prognosis","treatment and prognoses","treatment and prevention","treatment and outcomes","treatment and outcome","treatment and methods","treatment and further course","treatment and follow up","treatment and course","treatment and clinical course","treatment / results","treatment / prognosis","treatment / prognoses","treatment / prevention","treatment / outcomes","treatment / outcome","treatment / methods","treatment / further course","treatment / follow up","treatment / course","treatment / clinical course","treatment","tratamiento","tratamento estatistico","tratamento","trasfondo","transplantation","transmissions and prevention","transmissions / prevention","transmission and prevention","transmission / prevention","transmission","translational relevance","translation to health education practice","translation","transcript profiling","traitement","training system","training","trail registration number","trail registration","tracking compliance","toxoplasmosis","toxicological screening","toxicokinetics","toxicity","toxicities","toxic goitre is also frequent","topics covered","topics and methods","topics / methods","topics","topic","tools and methods","tools / methods","tools","tool development","tool","tomography","tolerance","tolerability","tnm staging system","tissues","tipo di studio","tipo de investigacion","tipo de estudio","timing","time points","time period","time of survey","time horizon","time frame","tillvagagangssatt","thyroid hormone concentrations","thrombolysis","thesis and discussions","thesis and discussion","thesis / discussions","thesis / discussion","thesis","theses and discussions","theses and discussion","theses / discussions","theses / discussion","these studies suggest the following","these results demonstrate that","these data indicate that","therapy and results","therapy and prognosis","therapy and prognoses","therapy and outcomes","therapy and outcome","therapy and follow up","therapy and course","therapy and conclusions","therapy and conclusion","therapy and clinical course","therapy / results","therapy / prognosis","therapy / prognoses","therapy / outcomes","therapy / outcome","therapy / follow up","therapy / course","therapy / conclusions","therapy / conclusion","therapy / clinical course","therapy","therapieziele","therapiewahl","therapiestrategien","therapies and prognosis","therapies and prognoses","therapies and outcomes","therapies and outcome","therapies and conclusions","therapies and conclusion","therapies / prognosis","therapies / prognoses","therapies / outcomes","therapies / outcome","therapies / conclusions","therapies / conclusion","therapieoptionen","therapieergebnisse","therapie","therapeutics","therapeutical aspects","therapeutical approach","therapeutic use","therapeutic strategy","therapeutic strategies","therapeutic possibilities","therapeutic perspectives","therapeutic options","therapeutic modalities","therapeutic methods","therapeutic management","therapeutic intervention","therapeutic implications","therapeutic efficacy","therapeutic choice","therapeutic approaches","therapeutic approach","therapeutic applications","theory into practice","theory generated","theory and methods","theory and discussions","theory and discussion","theory / methods","theory / discussions","theory / discussion","theory","theories and discussions","theories and discussion","theories / discussions","theories / discussion","theoretical rationale","theoretical perspective","theoretical model","theoretical framework","theoretical frame of reference","theoretical foundation","theoretical considerations","theoretical background","theoretical approaches","theoretic framework","themes","themenuberblick","theme","thema","the wider context","the technology being reviewed","the technology","the study","the solution","the role of herbs and spices in health","the role of herbs / spices in health","the results of the work","the results","the result","the research","the purpose of the study","the purpose of the research","the purpose","the proposed model","the project","the program","the problem","the principal results","the partnership","the outcomes dissemination project","the objective of the study","the objective","the next step","the nature and aim of the work","the nature / aim of the work","the model","the method","the materials and methods","the materials / methods","the material and methods","the material / methods","the main outcome measures","the main findings","the level of evidence","the investigation has two parts","the hypothesis","the goal of the study","the goal","the galvanic reaction","the future","the framework","the following results were obtained","the examinees and methods","the examinees / methods","the data indicate that","the conclusions","the conclusion","the case study","the case","the approach","the aims of our study were","the aims","the aim of work","the aim of this work","the aim of this study","the aim of this part of study","the aim of this paper","the aim of the work","the aim of the study was","the aim of the study","the aim of the present study","the aim of the paper","the aim of study","the aim of our study","the aim","tests","testing the hypothesis","testing of the hypothesis","testing of hypothesis","testing and implications of the hypothesis","testing / implications of the hypothesis","testing","tested documents","test persons and methods","test persons / methods","test materials","teori","teoretiskt ramverk","teoretiskt perspektiv","teoretiska perspektiv","teoretisk referensram","teoretisk rammeverk","teoretisk perspektiv","tema y alcance","tema / alcance","tema","tecnicas de investigacion","tecnicas","tecnica","technology","techniques","technique description","technique and results","technique / results","technique","technik","technics","technical report","technical features","technical development","technical considerations","technical aspects","technical","technic","teaching points","teaching method","teaching intervention","tb conclusions","taxonomic novelties","tavoite","taustaa","tasks","task force suggestions","task force recommendations","tasarım","tartısma","targets","target population","target group","target audience","target","tamano de la muestra","talentos y recursos","talentos y recurso","talentos / recursos","talentos / recurso","talento y recursos","talento y recurso","talento / recursos","talento / recurso","take home messages","take home message","take home massage","taches","systems","systematic review registration number","systematic review registration","systematic review methodology","system description","synthesis of the evidence","synthesis of evidence","synthesis methods","synthesis and findings","synthesis and conclusions","synthesis / findings","synthesis / conclusions","synthesis","synthese des donnees","synthese","synposis","synopsis and methods","synopsis / methods","synopsis","synonyms","symptoms and signs","symptoms / signs","symptoms","symptomatology","syftet","syften och problemformuleringar","syften och problemformulering","syften / problemformuleringar","syften / problemformulering","syfte och problemformuleringar","syfte och problemformulering","syfte och fragestallningar","syfte / problemformuleringar","syfte / problemformulering","syfte","sustainability","survival among pediatric recipients","survival among adult recipients","survival","surveys","survey sample","survey results","survey population","survey participants","survey methods","survey instrument","survey design and subjects","survey design / subjects","survey design","survey","surveillance","surgical treatment","surgical therapy","surgical techniques","surgical technique","surgical results","surgical relevance","surgical procedures","surgical procedure","surgical methods","surgical method","surgical management","surgical intervention","surgical approach","surgery","supporting evidence for the hypothesis","supporting data","supplementary material","supplementary information","supplementary data","supplemental material","summaryof background data","summary statements","summary statement","summary sentence","summary points","summary of work","summary of the findings","summary of the evidence","summary of the background data","summary of review","summary of results and findings","summary of results / findings","summary of results","summary of reports","summary of report","summary of recommendations","summary of methods utilized","summary of key points","summary of issues","summary of important findings","summary of findings and conclusions","summary of findings / conclusions","summary of findings","summary of evidence","summary of data","summary of content","summary of comment","summary of cases","summary of case","summary of backgrounds data","summary of background information","summary of background date","summary of background data","summary of background","summary of back ground data","summary background information","summary background data","summary background","summary answer","summary and recommendations","summary and impact on industry","summary and discussions","summary and discussion","summary and conclusions","summary and conclusion","summary and backgrounds","summary and background data","summary and background","summary / recommendations","summary / impact on industry","summary / discussions","summary / discussion","summary / conclusions","summary / conclusion","summary / backgrounds","summary / background data","summary / background","summary","summaries and discussions","summaries and discussion","summaries and conclusions","summaries and conclusion","summaries and backgrounds","summaries and background","summaries / discussions","summaries / discussion","summaries / conclusions","summaries / conclusion","summaries / backgrounds","summaries / background","sumario","sujets et methodes","sujets et methode","sujets / methodes","sujets / methode","sujetos y metodos","sujetos y metodo","sujetos de estudio","sujetos / metodos","sujetos / metodo","sujetos","sujeto y metodos","sujeto y metodo","sujeto de estudio","sujeto / metodos","sujeto / metodo","sujet et methodes","sujet et methode","sujet / methodes","sujet / methode","sujeitos e metodos","sujeitos e metodo","sujeitos / metodos","sujeitos / metodo","sujeito e metodos","sujeito e metodo","sujeito / metodos","sujeito / metodo","suggestions","suggestion","suggested solution","successes","success factors","subsequent investigation","subjets and methods","subjets and method","subjets / methods","subjets / method","subjects reviewed","subjects or participants","subjects of the study","subjects of study","subjects methods","subjects and treatments","subjects and treatment","subjects and study interventions","subjects and study design","subjects and settings","subjects and setting","subjects and results","subjects and research methods","subjects and patients","subjects and participants","subjects and outcome measures","subjects and methods","subjects and methodology","subjects and method","subjects and measures","subjects and measurements","subjects and materials","subjects and material","subjects and main outcome measures","subjects and location","subjects and interventions","subjects and intervention","subjects and instrumentation","subjects and experimental protocol","subjects and design","subjects and data","subjects / treatments","subjects / treatment","subjects / study interventions","subjects / study design","subjects / settings","subjects / setting","subjects / results","subjects / research methods","subjects / patients","subjects / participants","subjects / outcome measures","subjects / methods","subjects / methodology","subjects / method","subjects / measures","subjects / measurements","subjects / materials","subjects / material","subjects / main outcome measures","subjects / location","subjects / interventions","subjects / intervention","subjects / instrumentation","subjects / experimental protocol","subjects / design","subjects / data","subjects","subjectives","subjective and methods","subjective / methods","subjective","subject selection","subject sample","subject population","subject of study","subject eligibility criteria","subject and treatments","subject and treatment","subject and settings","subject and setting","subject and methods","subject and method","subject and interventions","subject and intervention","subject / treatments","subject / treatment","subject / settings","subject / setting","subject / methods","subject / method","subject / interventions","subject / intervention","subject","subiects and methods","subiects / methods","subgroups","subanalysis","stydy design","studylevel and applicability","studylevel and applicabilities","studylevel / applicability","studylevel / applicabilities","studylevel","studydesign","study variables","study units","study type","study tool","study synthesis and appraisal","study synthesis / appraisal","study synthesis","study subjects and sample","study subjects and methods","study subjects and design","study subjects / sample","study subjects / methods","study subjects / design","study subjects","study structure","study sources","study site","study settings","study setting and design","study setting / design","study setting","study selections","study selection or eligibility criteria","study selection criteria","study selection and methods","study selection and interventions","study selection and extraction","study selection and data synthesis","study selection and data sources","study selection and data extraction","study selection and data abstraction","study selection and analysis","study selection / methods","study selection / interventions","study selection / extraction","study selection / eligibility criteria","study selection / data synthesis","study selection / data sources","study selection / data extraction","study selection / data abstraction","study selection / analysis","study selection","study section","study samples","study sample and methodology","study sample / methodology","study sample","study results","study registration number","study registration","study rationale","study questions","study question","study purpose","study protocol","study procedures","study procedure","study populations","study population and setting","study population and results","study population and methods","study population and method","study population and design","study population / setting","study population / results","study population / methods","study population / method","study population / design","study population","study plan and methods","study plan / methods","study perspective","study period","study patients","study participants and setting","study participants and methods","study participants and interventions","study participants / setting","study participants / methods","study participants / interventions","study participants","study parameters","study outcomes","study outcome","study objectives and design","study objectives / design","study objectives","study objective and methods","study objective / methods","study objective","study number","study methods","study methodology","study method","study members","study measures","study materials","study material","study location","study limitations","study limitation","study level / applicability","study instrument","study inclusion criteria","study inclusion and exclusion criteria","study inclusion / exclusion criteria","study implications","study ii","study identifier","study identification and selection","study identification / selection","study identification","study hypothesis","study hypotheses","study groups","study group and methods","study group / methods","study group","study goals","study goal","study format","study findings","study factors","study factor","study exposure","study endpoints","study eligibility criteria participants and interventions","study eligibility criteria participants / interventions","study eligibility criteria and participants","study eligibility criteria and interventions","study eligibility criteria / participants","study eligibility criteria / interventions","study eligibility criteria","study eligibility","study duration","study desing","study designs and subjects","study designs and methods","study designs / subjects","study designs / methods","study designs","study design setting","study design patients and measurements","study design patients / measurements","study design methods","study design materials and methods","study design materials / methods","study design and type of participants","study design and subjects","study design and study methods","study design and size","study design and settings","study design and setting","study design and results","study design and purpose","study design and population","study design and patients","study design and participants","study design and outcomes","study design and outcome measures","study design and objectives","study design and objective","study design and methods","study design and methodology","study design and method","study design and measures","study design and measurements","study design and materials","study design and main outcome measures","study design and interventions","study design and data collection methods","study design and data collection","study design and data","study design / type of participants","study design / subjects","study design / study methods","study design / size","study design / settings","study design / setting","study design / results","study design / purpose","study design / population","study design / patients","study design / participants","study design / outcomes","study design / outcome measures","study design / objectives","study design / objective","study design / methods","study design / methodology","study design / method","study design / measures","study design / measurements","study design / materials","study design / main outcome measures","study design / interventions","study design / data collection methods","study design / data collection","study design / data","study design","study deign","study context","study cohort","study base","study area and population","study area / population","study area","study appraisal methods","study appraisal and synthesis methods","study appraisal and synthesis method","study appraisal and synthesis","study appraisal and methods","study appraisal / synthesis methods","study appraisal / synthesis method","study appraisal / synthesis","study appraisal / methods","study appraisal","study animals","study and methods","study and design setting","study and design","study aims","study aim","study / methods","study / design setting","study / design","study","studies undertaken","studies reviewed","studies included","studies","studienziele","studienziel","studienlage","studienergebnisse","studiendesign","studien","studied parameters","studie","stud design","structured digital abstract","structure of study","structure","strong point","strengths and limitations","strengths / limitations","strengths","strength of recommendation taxonomy sort","strength of recommendation taxonomy","strength of recommendation grade","strength of recommendation","strategy for change","strategy","strategies for improvement","strategies for change","strategies","stichproben","stichprobe","status","statistics and analysis","statistics and analyses","statistics analysis","statistics / analysis","statistics / analyses","statistics","statistical tests","statistical test","statistical power","statistical models","statistical methods used","statistical methods","statistical method used","statistical method","statistical evaluation","statistical analysis used","statistical analysis preformed","statistical analysis performed","statistical analysis and results","statistical analysis and main results","statistical analysis / results","statistical analysis / main results","statistical analysis","statistical analyses used","statistical analyses performed","statistical analyses","statistic analysis","statistic","statement of the problem","statement of significance","statement of relevance","statement of purpose","statement of problem","statement of ethics","statement of contribution","statement of conclusions and recommendations for clinical practice","statement of conclusions / recommendations for clinical practice","statement of conclusions","statement of conclusion","statement","state of the art and perspectives","state of the art and main points","state of the art / perspectives","state of the art / main points","state of the art","state of knowledges","state of knowledge","state of art and perspectives","state of art / perspectives","state of art","startpoints","starting point","standardverfahren","standards","standard treatment","standard radiological methods","speculation","spect","specimens and methods","specimens / methods","specimens","specimen population","specifically","specific objectives","specific objective","specific aims","specific aim","specialty","special features","sources used","sources of material","sources of information medline","sources of information","sources of evidence","sources of data","sources and study selection","sources and methods","sources / study selection","sources / methods","sources","source of information","source of data","source material","source","sonuclar ve tartısma","sonuclar ve oneriler","sonuclar / tartısma","sonuclar / oneriler","sonuclar","sonuc ve tartısma","sonuc ve oneriler","sonuc / tartısma","sonuc / oneriler","sonuc","solutions","solution","software availability","software","socialimplications","social implications","social history","social / implications","social","slutsatser","slutsats","size","situering","situation","situacion actual","sitio","site of study","sistema de hipotesis","sintesis de resultados","sintesis de los Datos","sintesis","sintese dos dados","sintese","sinopsis","simulations and results","simulations / results","simulations","simpulan","signs and symptoms","signs / symptoms","signs","significant outcomes","significant of results","significant conclusions","significant and impact of the study","significant / impact of the study","significances","significance statement","significance of the study","significance of the results","significance of the research","significance of the conclusions","significance of study","significance of results","significance of research","significance of impact of the study","significance of findings","significance impact of the study","significance and the impact of the study","significance and importance of the study","significance and implications","significance and impacts of the study","significance and impact of this study","significance and impact of the study","significance and impact of study","significance and impact","significance and imapct of the study","significance and contributions","significance and contribution","significance and conclusions","significance and conclusion","significance / the impact of the study","significance / importance of the study","significance / implications","significance / impacts of the study","significance / impact of this study","significance / impact of the study","significance / impact of study","significance / impact","significance / imapct of the study","significance / contributions","significance / contribution","significance / conclusions","significance / conclusion","significance","signficance and impact of the study","signficance / impact of the study","signficance","side effects","short summary","short abstract","settting","settng","settings and subjects","settings and subject","settings and samples","settings and sample population","settings and sample","settings and procedures","settings and procedure","settings and populations","settings and population","settings and patients","settings and patient","settings and participants","settings and participant","settings and methods","settings and method","settings and measures","settings and measurements","settings and measurement","settings and locations","settings and location","settings and interventions","settings and intervention","settings and designs","settings and design","settings and contexts","settings and context","settings / subjects","settings / subject","settings / samples","settings / sample population","settings / sample","settings / procedures","settings / procedure","settings / populations","settings / population","settings / patients","settings / patient","settings / participants","settings / participant","settings / methods","settings / method","settings / measures","settings / measurements","settings / measurement","settings / locations","settings / location","settings / interventions","settings / intervention","settings / designs","settings / design","settings / contexts","settings / context","settings","setting usa subjects","setting usa participants","setting uk participants","setting e partecipanti","setting design","setting and type of participants","setting and subjects","setting and subject","setting and study population","setting and study participants","setting and study design","setting and samples","setting and sample population","setting and sample","setting and results","setting and procedures","setting and procedure","setting and populations","setting and population","setting and patients","setting and patient","setting and participants","setting and participant","setting and methods","setting and method","setting and measures","setting and measurements","setting and measurement","setting and locations","setting and location","setting and interventions","setting and intervention","setting and duration of study","setting and designs","setting and design","setting and data sources","setting and data source","setting and contexts","setting and context","setting / type of participants","setting / subjects","setting / subject","setting / study population","setting / study participants","setting / study design","setting / samples","setting / sample population","setting / sample","setting / results","setting / procedures","setting / procedure","setting / populations","setting / population","setting / patients","setting / patient","setting / participants / intervention","setting / participants","setting / participant","setting / partecipanti","setting / methods","setting / method","setting / measures","setting / measurements","setting / measurement","setting / locations","setting / location","setting / interventions","setting / intervention","setting / duration of study","setting / designs","setting / design","setting / data sources","setting / data source","setting / contexts","setting / context","setting","series summary","series and methods","series / methods","series","selection procedures and interventions","selection procedures / interventions","selection procedures","selection procedure","selection of studies","selection criteria included","selection criteria for studies","selection criteria for included studies","selection criteria","selection","selected highlights","sede","secondary prevention","secondary outcomes","secondary outcome measures","secondary outcome measure","secondary objectives","secondary objective","secondary measures","secondary measurements","secondary category","secondary","second case","search strategy and selection criteria","search strategy and inclusion criteria","search strategy and evaluation method","search strategy / selection criteria","search strategy / inclusion criteria","search strategy / evaluation method","search strategy","search strategies","search methods used","search methods and selection criteria","search methods / selection criteria","search methods","search methodology","search method","search","screening tools","screening examination","screening and preventive strategies analyzed","screening / preventive strategies analyzed","screening","scopo dello studio","scopo del lavoro","scopo","scope of view","scope of the study","scope of the review","scope of the problem","scope of review","scope of results","scope and subjects","scope and sources","scope and methods","scope and methodology","scope and methodologies","scope and findings","scope and conclusions","scope and aims","scope / subjects","scope / sources","scope / methods","scope / methodology","scope / methodologies","scope / findings","scope / conclusions","scope / aims","scope","scientific significance and future directions","scientific significance / future directions","scientific significance","scientific question","scientific objective","scientific novelty","scientific background","scientific aim","schwerpunkte","schwerpunkt","schussfolgerung","schlßfolgerungen","schluβfolgerungen","schluβfolgerung","schluβ","schlußzusammenfassung","schlußsatze","schlußsatz","schlußfolgerungen","schlußfolgerung","schlußbetrachtung","schlußbemerkungen","schlußbemerkung","schluß zusammenfassung","schluß","schlussstze","schlussfolgerungen","schlussfolgerung","schlusse","schlufolgerungen","schlufolgerung","schlubetafolgerungen","sampling technique","sampling strategy","sampling and methods","sampling and method","sampling / methods","sampling / method","sampling","samples and settings","samples and setting","samples and procedures","samples and methods","samples and methodology","samples and methodologies","samples and method","samples and measurements","samples and measurement","samples and designs","samples and design","samples / settings","samples / setting","samples / procedures","samples / methods","samples / methodology","samples / methodologies","samples / method","samples / measurements","samples / measurement","samples / designs","samples / design","samples","sample study","sample size","sample selection","sample populations","sample population","sample and settings","sample and setting","sample and methods","sample and methodology","sample and methodologies","sample and method","sample and measurements","sample and measurement","sample and designs","sample and design","sample / settings","sample / setting","sample / methods","sample / methodology","sample / methodologies","sample / method","sample / measurements","sample / measurement","sample / designs","sample / design","sample","sammanfattning","salvage therapy","safety results","safety","rusults","rsults","rresults","root cause analysis","role of corticosteroids","role in endourology","risultato e conclusioni","risultato e conclusione","risultato / conclusioni","risultato / conclusione","risultati e conclusioni","risultati / conclusioni","risultati","risks","risk stratification","risk of bias assessment","risk factors","risk factor assessment","risk factor assessement","risk evaluation","risk adapted therapy","risikofaktoren","risikofaktor","riassunto","rezults","rezultaty","revisao sistematica","revisao de literatura","reviews and discussions","reviews and discussion","reviews / discussions","reviews / discussion","reviewers","review summary","review strategy","review results","review registration","review question","review process","review of the literature","review of literature","review methods and data sources","review methods / data sources","review methods","review method used","review method","review measures","review focus","review findings","review date","review criteria","review and updating","review and discussions","review and discussion","review and consensus processes","review / updating","review / discussions","review / discussion","review / consensus processes","review","reuslts","reults","retrospective study","retention","resykts","resutts","resuts","resutls","resumo da tese","resumen","resume problematique","resume","resulys","resultsl","results showed","results results","results result","results of the study","results of the studies","results of studies in children","results of studies in adults","results of studies","results of sensitivity analysis","results of sensitivity analyses","results of literature review","results of data synthesis","results of data review","results of data analysis","results of clinical studies","results of assessment","results of analysis","results conclusions","results conclusion","results and synthesis methods","results and summary","results and statistics","results and statistical analysis","results and state of the art","results and significance of the research","results and significance of results","results and significance","results and reflections","results and recommendations","results and perspectives","results and outcomes","results and outcome","results and observations","results and methods","results and methodology","results and major conclusion","results and main outcome measurements","results and main measurements","results and limitations","results and limitation","results and interpretations","results and interpretation","results and innovation","results and implications","results and impact on industry and government","results and findings","results and evaluation","results and discussions","results and discussion","results and discusion","results and dicussion","results and debates","results and data synthesis","results and course","results and condusion","results and conclusions","results and conclusion","results and complications","results and comparison with existing methods","results and comparison with existing method","results and comments","results and comment","results and clinical relevance","results and clinical course","results and anticipations","results and analysis","results and analyses","results amd conclusion","results / synthesis methods","results / summary","results / statistics","results / statistical analysis","results / state of the art","results / significance of the research","results / significance of results","results / significance","results / reflections","results / recommendations","results / perspectives","results / outcomes ( anticipated )","results / outcomes","results / outcome","results / observations","results / methods","results / methodology","results / major conclusion","results / main outcome measurements","results / main measurements","results / limitations","results / limitation","results / interpretations","results / interpretation","results / innovation","results / implications","results / impact on industry / government","results / findings","results / evaluation","results / discussions","results / discussion","results / discusion","results / dicussion","results / debates","results / data synthesis","results / course","results / condusion","results / conclusions","results / conclusion","results / complications","results / comparison with existing methods","results / comparison with existing method","results / comments","results / comment","results / clinical relevance","results / clinical course","results / anticipations","results / analysis","results / analyses","results ( effects / changes","results","resulted","resulte","resultats principaux","resultats et discussions","resultats et discussion","resultats et conclusions","resultats et conclusion","resultats / discussions","resultats / discussion","resultats / conclusions","resultats / conclusion","resultats","resultatet","resultater","resultaten","resultate","resultat och slutsatser","resultat och slutsats","resultat et discussions","resultat et discussion","resultat et conclusions","resultat et conclusion","resultat and slutsats","resultat / slutsatser","resultat / slutsats","resultat / discussions","resultat / discussion","resultat / conclusions","resultat / conclusion","resultat","resultados y discusiones","resultados y discusion","resultados y conclusiones","resultados y conclusion","resultados principales","resultados principal","resultados parciais","resultados obtenidos","resultados histopatologico","resultados esperados","resultados e discussoes","resultados e discussoes","resultados e discussao","resultados e conclusoes","resultados e conclusao","resultados e analise","resultados / discussoes","resultados / discussoes","resultados / discussao","resultados / discusiones","resultados / discusion","resultados / conclusoes","resultados / conclusiones","resultados / conclusion","resultados / conclusao","resultados / analise","resultados","resultado y discusiones","resultado y discusion","resultado y conclusiones","resultado y conclusion","resultado principal","resultado histopatologico","resultado e discussoes","resultado e discussoes","resultado e discussao","resultado e discussao","resultado e conclusoes","resultado e conclusao","resultado e analise","resultado / discussoes","resultado / discussoes","resultado / discussao","resultado / discussao","resultado / discusiones","resultado / discusion","resultado / conclusoes","resultado / conclusiones","resultado / conclusion","resultado / conclusao","resultado / analise","resultado","resultaat","resulta","result and outcomes","result and outcome","result and methodology","result and interpretations","result and interpretation","result and discussions","result and discussion","result and condusion","result and conclusions","result and conclusion","result and analysis","result and analyses","result analysis","result / outcomes","result / outcome","result / methodology","result / interpretations","result / interpretation","result / discussions","result / discussion","result / condusion","result / conclusions","result / conclusion","result / analysi","result / analyses","result","resulsts","resulst","resullts","resul ts","resuits","restults","responses and treatments","responses and treatment","responses / treatments","responses / treatment","responses","response to treatment","response and treatments","response and treatment","response / treatments","response / treatment","response","responders","respondents and methods","respondents / methods","respondents","resources and materials","resources / materials","resources","resolutions","resolution","resluts","resistance","resilts","reserch design and methods","reserch design / methods","research limitations / implications","researchlimitations / implications","research type","research topics","research strategy and methods","research strategy / methods","research strategy","research setting","research results","research recommendations","research questions","research question","research purposes","research purpose","research process","research procedure","research problem","research participants and methods","research participants / methods","research participants","research outcomes","research objectives","research objective","research methods and procedures","research methods and procedure","research methods and design","research methods / procedures","research methods / procedure","research methods / design","research methods","research methodology","research method and procedures","research method / procedures","research method","research limitations","research instruments","research implications and limitations","research implications / limitations","research implications","research hypothesis","research highlights","research focus","research findings","research designs and methods","research designs / methods","research designs","research design subjects","research design and subjects","research design and study sample","research design and settings","research design and setting","research design and participants","research design and methods and results","research design and methods","research design and methodology","research design and method","research design and measures","research design and context","research design and analysis","research design / subjects","research design / study sample","research design / settings","research design / setting","research design / participants","research design / methods / results","research design / methods","research design / methodology","research design / method","research design / measures","research design / context","research design / approach / method","research design / analysis","research design , approach and method","research design","research desgin","research context","research conclusions","research conclusion","research approach / design / method","research approach , design and method","research approach","research and practice implications","research and methods","research and implications","research and designs","research and design methods","research and design","research aims and design","research aims / design","research aim","research agenda","research / practice implications","research / methods","research / limitations / implications","research / implications","research / designs","research / design methods","research / design","research","reseach design and methods","reseach design / methods","resarch design and methods","resarch design / methods","res ults","requirements","reproducibility","reports","reporting period covered","reporting period","reporte del caso","reporte de casos","reporte de caso","report of the case","report of cases","report of case","report of a case","report case","report","replication","remarks","remark","reliability","relevant findings","relevant clinical criteria for the diagnosis of adam","relevant changes","relevant biological criteria for the diagnosis of adam","relevance to public health practice","relevance to professional practice","relevance to practice","relevance to industry","relevance to clinical practise","relevance to clinical practices","relevance to clinical practice","relevance to clinical or professional practice","relevance to clinical nursing","relevance to clinical care","relevance to clinical / professional practice","relevance to asian pacific islander american populations","relevance to asian american and pacific islander populations","relevance to asian american / pacific islander populations","relevance statement","relevance of clinical practice","relevance for practice","relevance for nursing practice","relevance for clinical practice","relevance","relato do casos","relato do caso","relato de caso","relative contraindications","relation to clinical practice","rehabilitation","regulatory status","regulatory requirements therapeutic equivalence","regulation","registry of protocol","registry","registrations","registration number","registration id in irct","registration details","registration","regimen","regarding treatment","regarding diagnosis","reflexiones finales","reflexion final","refleksjonsoppgaven","refleksjonsoppgave","refleksjonsnotat","reflections","reference tests","reference test or outcome","reference test / outcome","reference test","reference population","recurrence","recruitment","recommendations level ii","recommendations for prevention","recommendations for practice","recommendations for further research","recommendations for clinical practice","recommendations and perspectives","recommendations and perspective","recommendations and outlook","recommendations and conclusions","recommendations and conclusion","recommendations / perspectives","recommendations / perspective","recommendations / outlook","recommendations / conclusions","recommendations / conclusion","recommendations","recommendation and perspectives","recommendation and perspective","recommendation and outlook","recommendation / perspectives","recommendation / perspective","recommendation / outlook","recommendation","recommandations","recoleccion de datos","recent trends","recent studies","recent progress","recent literature data","recent findings and summary","recent findings / summary","recent findings","recent finding","recent examples","recent example","recent developments","recent data","recent advances and critical issues","recent advances / critical issues","recent advances","reasoning behind literature selection","reason","razoes","razionale","rationnel","rationale for the study","rationale and objectives","rationale and designs","rationale and design","rationale and backgrounds","rationale and background","rationale / objectives","rationale / designs","rationale / design","rationale / backgrounds","rationale / background","rationale","rational","rapport","randomization","randomisation","radiotherapy","radiopharmaceuticals","radiology","radiologische standardverfahren","radiological assessment","radiologic diagnosis","r esults","questoes importantes","questions under study","questions and purposes","questions and purpose","questions and hypothesis","questions and hypotheses","questions addressed","questions / purposes","questions / purpose","questions / hypothesis","questions / hypotheses","questions","questionnaires","questionnaire","question and purposes","question and purpose","question / purposes","question / purpose","question","questao","quality of studies","quality of life","quality of evidence medline","quality of evidence a medline","quality of evidence","quality of care interventions","quality improvement plan","quality assessment","quality","qualitative analysis","qualifying statements","purposes of the study","purposes and settings","purposes and setting","purposes and scope","purposes and questions","purposes and question","purposes and objectives","purposes and objective","purposes and methods","purposes and method","purposes and introduction","purposes and hypothesis","purposes and hypotheses","purposes and designs","purposes and design","purposes and clinical relevance","purposes and backgrounds","purposes and background","purposes and approaches","purposes and approach","purposes and aims","purposes and aim","purposes / settings","purposes / setting","purposes / scope","purposes / questions","purposes / question","purposes / objectives","purposes / objective","purposes / methods","purposes / method","purposes / introduction","purposes / hypothesis","purposes / hypotheses","purposes / designs","purposes / design","purposes / clinical relevance","purposes / backgrounds","purposes / background","purposes / approaches","purposes / approach","purposes / aims","purposes / aim","purposes","purpose of work","purpose of this study","purpose of the work","purpose of the study","purpose of the research","purpose of the paper","purpose of the article","purpose of study","purpose of review","purpose of project","purpose of program","purpose of our study","purpose of investigation","purpose of article","purpose and study design","purpose and sources of information","purpose and sources","purpose and settings","purpose and setting","purpose and scope","purpose and results","purpose and questions","purpose and question","purpose and objectives","purpose and objective","purpose and methods","purpose and method","purpose and introduction","purpose and hypothesis","purpose and hypotheses","purpose and designs","purpose and design","purpose and clinical relevance","purpose and backgrounds","purpose and background","purpose and approaches","purpose and approach","purpose and aims","purpose and aim","purpose / study design","purpose / sources of information","purpose / sources","purpose / settings","purpose / setting","purpose / scope","purpose / results","purpose / questions","purpose / question","purpose / objectives","purpose / objective","purpose / methods","purpose / method","purpose / introduction","purpose / hypothesis","purpose / hypotheses","purpose / designs","purpose / design","purpose / clinical relevance","purpose / backgrounds","purpose / background","purpose / approaches","purpose / approach","purpose / aims","purpose / aim","purpose","punto de vista","publication history","public health implications","public health and dietary implications","public health actions taken","public health actions","public health action","public health / dietary implications","psychotherapy","psychosocial factors","provisional clinical opinion","proven efficacy","protocolo","protocol registration number","protocol registration in prospero","protocol registration","protocol","prospero registry number","prospero registration number","prospero registration","prospects and projects","prospects / projects","prospects","prospectives","prospective study","prospect","propuestas","propuesta","proposta","propositos","proposito del estudio","proposito","propositions","proposition","proposicao","proposed solution","proposed program","proposed methods","proposed method","proposed changes","proposed approach","proposals and conclusions","proposals and conclusion","proposals / conclusions","proposals / conclusion","proposals","proposal and conclusions","proposal and conclusion","proposal / conclusions","proposal / conclusion","proposal","propiedades fisicoquimicas","prophylaxis","promising future implications","projets","projects and perspectives","projects / perspectives","projects","project setting","project objectives","project methods","project design","project description","progression","progress to date","progress since icpd","progress","programs","programme evaluation","programme description","programme approach","programme","program summary","program structure","program overview","program outcomes","program implementation","program evaluation","program development","program design","program description","program components","program","prognosis and treatments","prognosis and treatment","prognosis / treatments","prognosis / treatment","prognosis","prognoses and treatments","prognoses and treatment","prognoses / treatments","prognoses / treatment","prognosen","prognose","product comparison","produccion","process evaluation","process","procedures and results","procedures and methods","procedures and method","procedures and findings","procedures and designs","procedures and design","procedures / results","procedures / methods","procedures / method","procedures / findings","procedures / designs","procedures / design","procedures","procedure and results","procedure and methods","procedure and method","procedure and designs","procedure and design","procedure / results","procedure / methods","procedure / method","procedure / designs","procedure / design","procedure","procedimientos","procedimiento","procedimentos metodologicos","procedimentos metodologico","procedimentos","procedimento metodologicos","procedimento metodologico","problemstellung","problemstallning","problems and conditions","problems and condition","problems and backgrounds","problems and background","problems / conditions","problems / condition","problems / backgrounds","problems / background","problems","problemomrade","problemin tanımı","problemformulering","problemdiskussion","problembeskrivning","problembakgrund","problematisering","problematique","problematique","problematik","problematica","problema de investigacion","problema central","problema","problem statement","problem i cel","problem beskrivning","problem bakgrund","problem and conditions","problem and condition","problem and backgrounds","problem and background","problem analysis","problem / conditions","problem / condition","problem / backgrounds","problem / background","problem","probleemstelling","probleem","probands and methods","probands and method","probands / methods","probands / method","probands","probanden und methoden","probanden und methode","probanden / methoden","probanden / methode","probanden","proband und methoden","proband und methode","proband / methoden","proband / methode","probability function for diagnoses","principles","principle results","principle observations","principle findings","principle finding","principle conclusions","principle conclusion","principle","principios","principaux resultats","principales resultados","principales medidas de resultados","principales conclusiones","principal variables of interest","principal results","principal points","principal outcomes","principal outcome measures","principal outcome measure","principal observations","principal measures","principal measurements","principal measurement","principal findings and conclusions","principal findings / conclusions","principal findings","principal finding","principal conclusions","principal conclusion","principais resultados","primary variables of interest","primary variables","primary themes","primary study objectives","primary study objective","primary setting","primary results","primary recommendation","primary prevention","primary practice settings","primary practice setting","primary outcomes","primary outcome variables","primary outcome variable","primary outcome measures","primary outcome measurement","primary outcome measure","primary outcome and secondary outcome measures","primary outcome / secondary outcome measures","primary outcome","primary objectives","primary objective and hypothesis","primary objective / hypothesis","primary objective","primary measures","primary measurements","primary hypothesis","primary findings","primary endpoints","primary endpoint","primary diagnosis","primary data source","primary argument","primary and secondary outcomes measures","primary and secondary outcomes","primary and secondary outcome measures","primary and secondary outcome measure","primary and secondary measures","primary aim","primary / secondary outcomes measures","primary / secondary outcomes","primary / secondary outcome measures","primary / secondary outcome measure","primary / secondary measures","previous publication","previous presentation","previous classifications of dysphonias","preventive measures","prevention and therapy","prevention and therapies","prevention / therapy","prevention / therapies","prevention","prevalencia","prevalence estimation","prevalence","pretreatment records","pressure ulcer prevention","presentation of the hypothesis","presentation of the case","presentation of hypothesis","presentation of findings","presentation of cases","presentation of case","presentation of a case","presentation and interventions","presentation and intervention","presentation / interventions","presentation / intervention","presentation","presentacion del casos","presentacion del caso","presentacion de un caso","presentacion de los casos","presentacion de casos","presentacion de caso","presentacion de caso","presentacion","present status","present situation","present knowledge","present and future","present / future","prerequisites","prerequis","preparation and methods","preparation / methods","preparation","preoperative work up","preoperative evaluation","preoperative counseling and informed consent","preoperative counseling / informed consent","premissa","premesse","premessa","preliminary results","preliminary findings","pregunta problema","pregunta de investigacion","pregunta clinica","pregnancy","prefazione","predisposing factors","predictors of outcome","predictors and outcomes","predictors and factors","predictors and factor","predictors / outcomes","predictors / factors","predictors / factor","predictors","predictor variables","predictor variable","predictor or factor","predictor and factors","predictor and factor","predictor / factors","predictor / factor","predictor","precis","precautions","preambulo","pre requis","praventionen","pravention","praktische implikationen","practitioners","practitioner summary","practitioner points","practise implications","practices and policy","practices and policies","practices / policy","practices / policies","practice recommendations","practice pattern examined","practice or policy","practice innovation","practice implications","practice implication","practice guideline","practice description","practice conclusion","practice application","practice and research implications","practice and policy","practice and policies","practice / research implications","practice / policy","practice / policies","practice","practicalimplications","practical value and implications","practical value / implications","practical value","practical significance","practical relevance","practical recommendations","practical methods","practical meaningfulness","practical meaning","practical management","practical implications and value","practical implications and conclusions","practical implications / value","practical implications / conclusions","practical implications","practical implication","practical implementation","practical consequences","practical conclusions","practical attitude","practical applications","practical application","practical and managerial implications","practical / managerial implications","practical / implications","pracitcal importance","pppgavas hensik","power calculations","potential significance","potential relevance","potential outcomes","potential limitations","potential interventions","potential intervention","potential difficulties","potential clinical relevance","postoperative treatment","postoperative results","postoperative regimen","postoperative management","postoperative course","postoperative complications","postoperative care","postimplementation results","possible solutions","possible roles","possible complications","positive results","positive diagnosis","positions","positionings and anaesthesia","positionings / anaesthesia","positioning and anaesthesia","positioning / anaesthesia","positioning","populations and settings","populations and setting","populations and samples","populations and sample","populations and methods","populations and methodology","populations and methodologies","populations and method","populations and interventions","populations and intervention","populations / settings","populations / setting","populations / samples","populations / sample","populations / methods","populations / methodology","populations / methodologies","populations / method","populations / interventions","populations / intervention","populations","population studied","population sample","population or sample","population of sample","population et methodes","population et methode","population and study design","population and settings","population and setting","population and samples","population and sample size","population and sample","population and results","population and methods","population and methodology","population and methodologies","population and method","population and interventions","population and intervention","population / study design","population / settings","population / setting","population / samples","population / sample size","population / sample","population / results","population / methods","population / methodology","population / methodologies","population / methodes","population / methode","population / method","population / interventions","population / intervention","population","populacao","policy practice","policy implications","policy","policies","points of consensus","points justifiant l’etude","points discussed","point of view","podsumowanie","poblation and methods","poblation / methods","poblacion y muestra","poblacion y metodos","poblacion y metodo","poblacion de estudio","poblacion / muestra","poblacion / metodos","poblacion / metodo","poblacion / material / metodos","poblacion , material y metodos","poblacion","pneumoniae","plausibility","plasma","plaque formation","planteamiento del problema","planteamiento","plans for guideline revision","planning care","planning","plan of action","plan","plain language summary","places and duration","places / duration","placement","place of the study","place of study","place of application","place in therapy","place and participants","place and duration of the study","place and duration of study","place and duration","place / participants","place / duration of the study","place / duration of study","place / duration","pitfalls","physiopathology","physician qualification","physical properties","physical examination and management","physical examination / management","physical examination","physical characteristics","phenomenon of interest","phenomena of interest","pharmacology","pharmacological properties","pharmacologic properties","pharmacokinetics and pharmacodynamics","pharmacokinetics / pharmacodynamics","pharmacokinetics","pharmacokinetic properties","pharmacokinetic and statistical analysis","pharmacokinetic / statistical analysis","pharmacokinetic","pharmacoeconomics","pharmacodynamics","pharmacodynamic properties","perspektiven","perspektive","perspectives and projets","perspectives and projects","perspectives and future projects","perspectives and conclusions","perspectives and conclusion","perspectives / projets","perspectives / projects","perspectives / future projects","perspectives / conclusions","perspectives / conclusion","perspectives","perspective and projects","perspective and conclusions","perspective and conclusion","perspective / projects","perspective / conclusions","perspective / conclusion","perspective","persons and methods","persons / methods","persons","personnel","personal experience","peritoneal washings cytology","period of study","period covered","period","performance","pdsumowanie","pazienti e metodi","pazienti / metodi","patients y methods","patients subjects and methods","patients subjects / methods","patients sample","patients participants","patients or subjects","patients or study participants","patients or participants","patients or others participants","patients or other participants","patients or materials","patients methods and results","patients methods / results","patients methods","patients material and methods","patients material / methods","patients et methods","patients et methodes","patients et methode","patients et method","patients e methods","patients characteristics","patients at risk","patients andmethods","patients and treatments","patients and treatment protocol","patients and treatment","patients and therapy","patients and techniques","patients and technique","patients and subjects","patients and study design","patients and settings","patients and setting","patients and samples","patients and results","patients and protocol","patients and procedures","patients and participants","patients and outcome measures","patients and outcome","patients and others taking part","patients and others participants","patients and other participants","patients and operations","patients and metods","patients and methods and results","patients and methods","patients and methodology","patients and methodes","patients and method","patients and mehtods","patients and measures","patients and measurements","patients and measurement","patients and materials","patients and material","patients and main results","patients and main outcome measures","patients and main outcome measurements","patients and interventions","patients and intervention","patients and findings","patients and experimental design","patients and designs","patients and design","patients and data sets","patients and controls","patients and control subjects","patients and clinical isolates","patients and animals","patients an methods","patients adn methods","patients / treatments","patients / treatment protocol","patients / treatment","patients / therapy","patients / techniques","patients / technique","patients / subjects","patients / study participants","patients / study design","patients / settings","patients / setting","patients / samples","patients / results","patients / protocol","patients / procedures","patients / participants","patients / outcome measures","patients / outcome","patients / others taking part","patients / others participants","patients / other participants","patients / operations","patients / metods","patients / methods / results","patients / methods","patients / methodology","patients / methodes","patients / methode","patients / method","patients / mehtods","patients / measures","patients / measurements","patients / measurement","patients / materials","patients / material","patients / main results","patients / main outcome measures","patients / main outcome measurements","patients / interventions","patients / intervention","patients / findings","patients / experimental design","patients / designs","patients / design","patients / data sets","patients / controls","patients / control subjects","patients / clinical isolates","patients / animals","patients","patientes et methodes","patientes and methods","patientes / methods","patientes","patienten und methodik","patienten und methoden","patienten und methode","patienten / methodik","patienten / methoden","patienten / methode","patienten","patiente und methoden","patiente / methoden","patient und methode","patient summary","patient selection and methods","patient selection / methods","patient selection","patient samples","patient sample and outcome measures","patient sample and methodology","patient sample and method","patient sample / outcome measures","patient sample / methodology","patient sample / method","patient sample","patient reports","patient report","patient presentation","patient populations","patient population and methods","patient population / methods","patient population","patient participants","patient outcomes","patient or study population","patient material and methods","patient material / methods","patient material","patient interventions","patient history and clinical findings","patient history / clinical findings","patient history","patient groups","patient group and methods","patient group / methods","patient group","patient findings and summary","patient findings / summary","patient findings","patient et methodes","patient et methode","patient details","patient description","patient data","patient cohort and methods","patient cohort / methods","patient cohort","patient characteristics","patient case","patient care level","patient and treatments","patient and treatment","patient and techniques","patient and technique","patient and settings","patient and setting","patient and results","patient and methods","patient and method","patient and materials","patient and material","patient and interventions","patient and intervention","patient and findings","patient and designs","patient and design","patient and case report","patient / treatments","patient / treatment","patient / techniques","patient / technique","patient / study population","patient / settings","patient / setting","patient / results","patient / methods","patient / methodes","patient / methode","patient / methode","patient / method","patient / materials","patient / material","patient / interventions","patient / intervention","patient / findings","patient / designs","patient / design","patient / case report","patient","patiens and methods","patiens and method","patiens / methods","patiens / method","patiends and methods","patiends / methods","paticipants","pathophysiology","pathophysiologie","pathophysiological hypotheses","pathology findings","pathology","pathological findings and diagnosis","pathological findings / diagnosis","pathological findings","pathologic features","pathogenicity","pathogenic hypotheses","pathogenetic mechanisms","pathogenesis","pathogenese","patents and methods","patents / methods","patents","participation","participants or samples","participants in development of scientific statement","participants and testing","participants and study design","participants and settings","participants and setting","participants and samples","participants and research context","participants and procedures","participants and procedure","participants and patients","participants and outcome measures","participants and methods","participants and method summary","participants and method","participants and measures","participants and measurements","participants and materials","participants and main outcome measures","participants and interventions","participants and intervention","participants and exposures","participants and design","participants and data collection","participants and data","participants and controls","participants and contexts","participants and context","participants and consensus process","participants and approach","participants / testing","participants / study design","participants / settings","participants / setting","participants / samples","participants / research context","participants / procedures","participants / procedure","participants / patients","participants / outcome measures","participants / methods","participants / method summary","participants / method","participants / measures","participants / measurements","participants / materials","participants / main outcome measures","participants / interventions","participants / intervention","participants / exposures","participants / design","participants / data collection","participants / data","participants / controls","participants / contexts","participants / context","participants / consensus process","participants / approach","participants","participantes","participant sample","participant and interventions","participant / interventions","participant","partecipants","partcipants","parasites","parametros avaliados","parameters studied","parameters evaluated","parameters","paperflick","paperclip","palivizumab outcomes registry","pain","paients and methods","paients / methods","pacjent i metody","pacjent i metoda","pacjent / metody","pacjent / metoda","pacjenci i metody","pacjenci i metoda","pacjenci / metody","pacjenci / metoda","pacientes y metodos","pacientes y metodo","pacientes e metodos","pacientes / metodos","pacientes / metodo","pacientes","paciente y metodos","paciente y metodo","paciente / metodos","paciente / metodo","paatelma","own experiences","overzicht","overview of the literature","overview of literature","overview","overall strength of evidence","overall learning objective","overall conclusions","overall article objectives","overall article objective","overall approach to quality and safety","overall approach to quality / safety","outputs","output","outpatient measure","outlook","outline of the model","outline of review","outline of cases","outline","outcomes summary","outcomes of interest","outcomes measures","outcomes measurements and statistical analysis","outcomes measurements and statistical analyses","outcomes measurements / statistical analysis","outcomes measurements / statistical analyses","outcomes measurement","outcomes measured","outcomes measure","outcomes assessment","outcomes and results","outcomes and methods","outcomes and measurements","outcomes and conclusion","outcomes and analysis","outcomes / results","outcomes / methods","outcomes / measurements","outcomes / conclusion","outcomes / analysis","outcomes","outcome variables","outcome variable","outcome principali","outcome parameters","outcome measures and subjects","outcome measures and statistical analysis","outcome measures and results","outcome measures and methods","outcome measures and analysis","outcome measures / subjects","outcome measures / statistical analysis","outcome measures / results","outcome measures / methods","outcome measures / analysis","outcome measures","outcome measurements and statistical analysis","outcome measurements and statistical analyses","outcome measurements and results","outcome measurements / statistical analysis","outcome measurements / statistical analyses","outcome measurements / results","outcome measurements","outcome measurement and statistical analysis","outcome measurement / statistical analysis","outcome measurement","outcome measured","outcome measure and results","outcome measure / results","outcome measure","outcome evaluation","outcome definition","outcome criteria","outcome assessment","outcome and results","outcome and measurements","outcome / results","outcome / measurements","outcome","outbreak","our results","other treatments","other participants","other outcomes","other measurements","other findings","other factors","oryginalnosc pracy","oryginalnosc i wartosci","oryginalnosc i wartosc","oryginalnosc / wartosci","oryginalnosc / wartosc","oryginalnosc","origniality / value","origins of information","originalty / value","originality value","originality and values","originality and value","originality and approaches","originality and approach","originality / valve","originality / values","originality / value / contribution","originality / value","originality / vale","originality / practical implications","originality / approaches","originality / approach","originality / value","originality","originalita del lavoro","originalidade e valores","originalidade e valor","originalidade / valores","originalidade / valor","originalidade","originalidad y valores","originalidad y valor","originalidad / valores","originalidad / valor","originalidad","original value","original positions","original article","original / value","origem historica","origem","orginality / value","organizing framework","organizing constructs","organizing construct and scope","organizing construct and methods","organizing construct / scope","organizing construct / methods","organizing construct","organizational construct","organization level","organization","organisms","oral anticoagulation","opzet","options and outcomes","options / outcomes","options","oppsummering","opportunities","oppgavens hensikt","opis przypadku","opioids","opinion","operative technique","operative procedure","operative management","operative findings","operationsziel","operationstechnik","operationsprinzip","operations","operational issues","operation technique","operation and results","operation / results","operation","open questions","open peer review","ongoing issues","one sentence summary","onderzoeksvraag","onderwerp","onclusion","omowienie wynikow","omowienie","olgu sunumu","olgu raporu","olgu","ojetivo","ojectives","ojective","ograniczenia badan i wnioskowania","ograniczenia badan / wnioskowania","oggetto","obxectivos","obxectivo","obtencion de datos","obtained results","obstacles","observed and assessed population","observed / assessed population","observations and results","observations and methods","observations and conclusions","observations / results","observations / methods","observations / conclusions","observations","observational procedure","observation procedures","observation procedure","observation and results","observation / results","observation","observacional","objeto de estudo","objeto","objetivos y metodos","objetivos y metodologia","objetivos y metodo","objetivos ou proposicao","objetivos especificos","objetivos e principios","objetivos e hipotesis","objetivos do estudo","objetivos del trabajo","objetivos del proyecto","objetivos del estudio","objetivos de la investigacion","objetivos / proposicao","objetivos / principios","objetivos / metodos","objetivos / metodologia","objetivos / metodo","objetivos / hipotesis","objetivos","objetivo y metodos","objetivo y metodologia","objetivo y metodo","objetivo principal","objetivo geral","objetivo general","objetivo especifico","objetivo e importancia","objetivo do estudo","objetivo del estudio","objetivo de la investigacion","objetivo de la investigacio","objetivo / metodos","objetivo / metodologia","objetivo / metodo","objetivo / importancia","objetivo","objetives","objetive","objet","objectıve","objects and methods","objects and method","objects / methods","objects / method","objects","objectivos","objectivo","objectives of the study","objectives of the review","objectives of the program","objectives of review","objectives of program","objectives and study design","objectives and settings","objectives and setting","objectives and scope","objectives and results","objectives and purposes","objectives and purpose","objectives and patients","objectives and methods","objectives and methodology","objectives and methodologies","objectives and method","objectives and main outcome measures","objectives and interventions","objectives and intervention","objectives and hypothesis","objectives and hypotheses","objectives and goals","objectives and goal","objectives and discussions","objectives and discussion","objectives and designs","objectives and design","objectives and contexts","objectives and context","objectives and conclusions","objectives and conclusion","objectives and backgrounds","objectives and background","objectives and aims","objectives and aim","objectives / study design","objectives / settings","objectives / setting","objectives / scope","objectives / results","objectives / purposes","objectives / purpose","objectives / patients","objectives / methods","objectives / methodology","objectives / methodologies","objectives / method","objectives / main outcome measures","objectives / interventions","objectives / intervention","objectives / hypothesis","objectives / hypotheses","objectives / goals","objectives / goal","objectives / discussions","objectives / discussion","objectives / designs","objectives / design","objectives / contexts","objectives / context","objectives / conclusions","objectives / conclusion","objectives / backgrounds","objectives / background","objectives / aims","objectives / aim","objectives","objective of the study","objective of the research","objective of the program","objective of study","objective of review","objective of research","objective of program","objective of conference participants","objective and subjects","objective and study design","objective and settings","objective and setting","objective and results","objective and research design","objective and purposes","objective and purpose","objective and perspectives","objective and patients","objective and participants","objective and methods","objective and methodology","objective and methodologies","objective and method","objective and main outcome measures","objective and interventions","objective and intervention","objective and hypothesis","objective and hypotheses","objective and discussions","objective and discussion","objective and designs","objective and design","objective and contexts","objective and context","objective and conclusions","objective and conclusion","objective and case report","objective and backgrounds","objective and background","objective and aims","objective and aim","objective / subjects","objective / study design","objective / settings","objective / setting","objective / results","objective / research design","objective / purposes","objective / purpose","objective / perspectives","objective / patients","objective / participants","objective / methods","objective / methodology","objective / methodologies","objective / method","objective / main outcome measures","objective / interventions","objective / intervention","objective / hypothesis","objective / hypotheses","objective / discussions","objective / discussion","objective / designs","objective / design","objective / contexts","objective / context","objective / conclusions","objective / conclusion","objective / case report","objective / backgrounds","objective / background","objective / aims","objective / aim","objective","objectius","objectiu","object of study","object of research","object and methods","object and method","object / methods","object / method","object","obiettivo del paper","obiettivi specifici","obiettivi","obiective","obbiettivo","nursing implications","numbers","number of subjects","number of studies","null hypothesis","novelty of the work","novelty","novel experiments","norm","nocoes preliminares","nocoes gerais","nivel de evidencia","next steps","news and key points","news / key points","new therapeutic strategies","new therapeutic approaches","new technology","new strategies","new or unique information provided","new methods","new method","new information provided","new information","new findings","new features","new devices","new developments","new data","new approaches","new / unique information provided","neuropsychological domains","needs and purposes","needs and purpose","needs / purposes","needs / purpose","need and purposes","need and purpose","need / purposes","need / purpose","need","natureza e finalidade","natureza / finalidade","nature of the study","natural history","name of the trial registry","name of registry","name and place of department","name and address of the institution","name / place of department","name / address of the institution","nachbehandlung","mwrhosa and results","mwrhosa / results","muestreo","muestra","mrthods","motivations","motivation for the study","motivation for study","motivation","motivacion del estudio","motivacao","most important findings","mortality and morbidity","mortality / morbidity","mortality","morbidity and mortality","morbidity / mortality","morbidity","monitoring","molecular genetics","modszertan","modszerek","modszer","models","modelo de estudo","modelling methods","modelling","modeling","model development","model design","model description","model and methods","model / methods","model","mode of action","mmethods","miniabstract","mini abstract","microbiological properties","metotlar","metot","metologia","metody","metods","metodos y tecnicas","metodos y resultados","metodos y resultado","metodos y materiales","metodos y material","metodos / tecnicas","metodos / resultados","metodos / resultado","metodos / materiales","metodos / material","metodos","metodos","metodoloxia","metodologia","metodo y resultados","metodo y resultado","metodo y materiales","metodo y material","metodo e strumento","metodo e strumenti","metodo de trabajo","metodo / strumento","metodo / strumenti","metodo / resultados","metodo / resultado","metodo / materiales","metodo / material","metodo","metodi e strumenti","metodi / strumenti","metodi","metodes","metoder och material","metoder och genomforande","metoder / material","metoder / genomforande","metoder","metoden","metode","metoda","metod och material","metod och genomforande","metod / material","metod / genomforande","metod","methos","methords","methology","methods used in the study","methods used","methods results","methods or interventions","methods or design","methods of the review","methods of study selection","methods of study","methods of analysis","methods methods","methods findings","methods experimental design","methods design","methods and techniques","methods and technique","methods and subjects","methods and subjective","methods and study population","methods and study design","methods and settings","methods and setting","methods and scope","methods and samples","methods and sample","methods and resutls","methods and results","methods and result","methods and procedures","methods and procedure","methods and principal results","methods and principal findings","methods and populations","methods and population","methods and patients","methods and patient population","methods and patient group","methods and patient","methods and participants","methods and outcomes","methods and outcome measures","methods and outcome measurements","methods and observations","methods and objectives","methods and objective","methods and methods","methods and measures","methods and measurements","methods and materials","methods and material","methods and major findings","methods and main results","methods and main outcomes","methods and main outcome measures","methods and main observations","methods and main findings","methods and key results","methods and interventions","methods and intervention","methods and information sources","methods and focus","methods and findings","methods and finding","methods and discussion","methods and designs","methods and design","methods and data sources","methods and data","methods and conclusions","methods and clinical material","methods and cases","methods and approaches","methods and approach","methods and analysis","methods and analyses","methods and aims","methods and aim","methods / techniques","methods / technique","methods / subjects","methods / subjective","methods / study population","methods / study design","methods / settings","methods / setting","methods / scope","methods / samples","methods / sample","methods / resutls","methods / results / discussion","methods / results","methods / result","methods / procedures","methods / procedure","methods / principal results","methods / principal findings","methods / populations","methods / population","methods / patients","methods / patient population","methods / patient group","methods / patient","methods / participants","methods / outcomes","methods / outcome measures","methods / outcome measurements","methods / observations","methods / objectives","methods / objective","methods / methods","methods / measures","methods / measurements","methods / materials","methods / material","methods / major findings","methods / main results","methods / main outcomes","methods / main outcome measures","methods / main observations","methods / main findings","methods / key results","methods / interventions","methods / intervention","methods / information sources","methods / focus","methods / findings","methods / finding","methods / discussion","methods / designs","methods / design","methods / data sources","methods / data","methods / conclusions","methods / clinical material","methods / cases","methods / approaches","methods / approach","methods / analysis","methods / analyses","methods / aims","methods / aim","methods","methodos","methodology principal findings","methodology and significant findings","methodology and samples","methodology and sample","methodology and results","methodology and research methods","methodology and principle findings","methodology and principal findings","methodology and principal finding","methodology and patients","methodology and participants","methodology and main findings","methodology and findings","methodology and designs","methodology and design","methodology and description","methodology and approaches","methodology and approach","methodology / significant findings","methodology / samples","methodology / sample","methodology / results","methodology / research methods","methodology / principle findings","methodology / principal findings","methodology / principal finding","methodology / patients","methodology / participants","methodology / main findings","methodology / findings","methodology / designs","methodology / design / approach","methodology / design","methodology / description","methodology / approaches","methodology / approach","methodology","methodologies and samples","methodologies and sample","methodologies and designs","methodologies and design","methodologies and approaches","methodologies and approach","methodologies / samples","methodologies / sample","methodologies / designs","methodologies / design","methodologies / approaches","methodologies / approach","methodologies","methodological quality","methodological procedures","methodological issues","methodological innovations","methodological design and justification","methodological design / justification","methodological design","methodological aspects","methodological approach","methodologic approach","methodolgy","methodogy","methodik und stichprobe","methodik / stichprobe","methodik","methodics","methodical innovations","methodic","methodes","methoden und stichproben","methoden und stichprobe","methoden und patienten","methoden und patient","methoden und ergebnisse","methoden und ergebnis","methoden / stichproben","methoden / stichprobe","methoden / patienten","methoden / patient","methoden / ergebnisse","methoden / ergebnis","methoden","methode und stichproben","methode und stichprobe","methode und patienten","methode und patient","methode und ergebnisse","methode und ergebnis","methode / stichproben","methode / stichprobe","methode / patienten","methode / patient","methode / ergebnisse","methode / ergebnis","methode","method summary","method of the study","method of study selection","method of study","method of review","method of research","method of literature search","method of approach","method of analysis","method design","method and subjects","method and study design","method and settings","method and setting","method and scope","method and samples","method and sample","method and results","method and result","method and procedures","method and procedure","method and populations","method and population","method and patients","method and patient","method and participants","method and objectives","method and objective","method and measures","method and materials","method and material","method and findings","method and designs","method and design","method and clinical material","method and approaches","method and approach","method and analysis","method and analyses","method / subjects","method / study design","method / settings","method / setting","method / scope","method / samples","method / sample","method / results","method / result","method / procedures","method / procedure","method / populations","method / population","method / patients","method / patient","method / participants","method / objectives","method / objective","method / measures","method / materials","method / material","method / findings","method / designs","method / design","method / clinical material","method / approaches","method / approach","method / analysis","method / analyses","method","methhods","metheds","methdos","methdology","meterials and methods","meterials / methods","mesurements","message principal","message of the paper","message","menetelma","meld off","mehtohds","mehtods","mehtod","mehods","medidas","mediciones y resultados principales","mediciones y resultados","mediciones y resultado","mediciones principales","mediciones / resultados","mediciones / resultado","mediciones","medicion y resultados","medicion y resultado","medicion / resultados","medicion / resultado","medication","medical treatment","mechanisms of toxicity","mechanisms of action","mechanisms","mechanism of action","measurments","measures of outcome","measures and results","measures and outcomes","measures and methods","measures and main results","measures and findings","measures and analysis","measures and analyses","measures / results","measures / outcomes","measures / methods","measures / main results","measures / findings","measures / analysis","measures / analyses","measures","measurements and results","measurements and result","measurements and outcomes","measurements and methods","measurements and mean results","measurements and main results","measurements and main result","measurements and main outcomes","measurements and main outcome","measurements and main findings","measurements and interventions","measurements and intervention","measurements and findings","measurements and design","measurements and analysis","measurements / results","measurements / result","measurements / outcomes","measurements / methods","measurements / mean results","measurements / main results","measurements / main result","measurements / main outcomes","measurements / main outcome","measurements / main findings","measurements / interventions","measurements / intervention","measurements / findings","measurements / design","measurements / analysis","measurements","measurement protocol","measurement outcomes","measurement and results","measurement and main results","measurement and main result","measurement and interventions","measurement and intervention","measurement and findings","measurement / results","measurement / main results","measurement / main result","measurement / interventions","measurement / intervention","measurement / findings","measurement","measure of outcome","measure","means and methods","means / methods","means","mean outcome measures","mean outcome measurements","mean outcome measure","matrials and methods","matrials / methods","mathods","matherials and methods","matherials / methods","matherial and methods","matherial and method","matherial / methods","matherial / method","materyal ve metot","materyal / metot","materiels et methodes","materiels et methode","materiels and methods","materiels / methods","materiels / methodes","materiels / methode","materiels","materiel et methode","materiel and methods","materiel / methods","materiel / methodes","materiel / methode","materiel","materiały i metodyka","materiały i metody","materiały i metoda","materiały / metodyka","materiały / metody","materiały / metoda","materiał i metodyka","materiał i metody","materiał i metoda","materiał / metodyka","materiał / metody","materiał / metoda","materialsand methods","materials or subjects","materials of study","materials methods","materials i metodes","materials i metode","materials et methods","materials ans methods","materials and treatments","materials and treatment","materials and surgical technique","materials and subjects","materials and results","materials and procedures","materials and patients","materials and methods and results","materials and methods","materials and methodology","materials and methodologies","materials and method","materials and materials","materials and interventions","materials and discussions","materials and discussion","materials and design","materials ad methods","materials / treatments","materials / treatment","materials / surgical technique","materials / subjects","materials / results","materials / procedures","materials / patients","materials / metodes","materials / metode","materials / methods / results","materials / methods","materials / methodology","materials / methodologies","materials / method","materials / materials","materials / interventions","materials / discussions","materials / discussion","materials / design","materials","materiali e metodo","materiali e metodi","materiali / metodo","materiali / metodi","materiali","materiales y metodos","materiales y metodo","materiales / metodos","materiales / metodo","materiales","materialer og metoder","materialer og metode","materialer / metoder","materialer / metode","materiale og metoder","materiale og metode","materiale e metodo","materiale e metodi","materiale / metodo","materiale / metodi","materiale / metoder","materiale / metode","materialand methods","material y metodos","material y metodologia","material y metodo","material y methods","material y method","material und methods","material und methoden","material und methode","material or subjects","material of study","material och metoder","material och metod","material methods","material method","material i metody","material i metodes","material i metode","material et methods","material et method","material e metodos","material e metodo","material e methods","material ans methods","material and treatments","material and treatment","material and surgical technique","material and subjects","material and results","material and patients","material and methods","material and methodology","material and methodologies","material and methodic","material and methodes","material and method","material and mehods","material and interventions","material and discussions","material and discussion","material an methods","material amd methods","material a methods","material / treatments","material / treatment","material / surgical technique","material / subjects","material / results","material / patients","material / metodos","material / metodologia","material / metodo","material / metodes","material / metoder","material / metode","material / metod","material / methods","material / methodology","material / methodologies","material / methodic","material / methodes","material / methoden","material / methode","material / method","material / mehods","material / interventions","material / discussions","material / discussion","material","materiais e metodos","materiais e metodo","materiais / metodos","materiais / metodo","materiaal","materal and methods","materal / methods","mateials and methods","mateials / methods","marital status","marcos de referencia","marco teorico","marco normativo","marco metodologico","marco historico","marco filosofico","marco conceptual","management of refractory disease","management","malformulering","malades et methodes","malades et methode","malades / methodes","malades / methode","malades","malade et methodes","malade et methode","malade / methodes","malade / methode","major topics","major results","major points","major outcomes","major outcome methods","major outcome measures","major outcome measure","major findings","major conclusions and general significance","major conclusions / general significance","major conclusions","major conclusion","main variables studied","main variables of interest","main variables examined","main variables","main variable","main updating","main topics","main study measures","main results and the role of chance","main results and role of chance","main results and measurements","main results and conclusions","main results / the role of chance","main results / role of chance","main results / measurements","main results / conclusions","main results","main result","main research variables","main research variable","main research concepts","main research classifications","main recommendations","main purpose","main points discussed","main points","main parameters","main outcomes measures","main outcomes measurements","main outcomes measurement","main outcomes measured","main outcomes measure","main outcomes and results","main outcomes and measures","main outcomes and measure","main outcomes / results","main outcomes / measures","main outcomes / measure","main outcomes","main outcomemeasures","main outcome variables","main outcome variable","main outcome results","main outcome parameters","main outcome of interest","main outcome methods","main outcome measures and results","main outcome measures and methods","main outcome measures and design","main outcome measures and analysis","main outcome measures / results","main outcome measures / methods","main outcome measures / design","main outcome measures / analysis","main outcome measures","main outcome measurements and results","main outcome measurements / results","main outcome measurements","main outcome measurement","main outcome measured","main outcome measure and results","main outcome measure / results","main outcome measure","main outcome findings","main outcome criteria","main outcome and results","main outcome and measures","main outcome and measurements","main outcome and measure","main outcome / results","main outcome / measures","main outcome / measurements","main outcome / measure","main outcome","main observations and results","main observations / results","main observations","main observation","main objective","main methods and key findings","main methods / key findings","main methods","main method","main messages","main message","main measures of outcome","main measures and results","main measures / results","main measures","main measurements and results","main measurements / results","main measurements","main measurement","main measure","main issues","main indications","main independent variables","main findings and conclusions","main findings and conclusion","main findings / conclusions","main findings / conclusion","main findings","main finding","main features","main exposures","main exposure measures","main exposure measure","main exposure","main endpoints","main endpoint","main contributions","main contribution","main contents","main conclusions","main conclusion","main components of the program","main components of program","main and secondary outcome measures","main / secondary outcome measures","m ethods","lugar y sujetos","lugar de aplicacion","lugar / sujetos","lugar","losungsansatz","losungen","losung","looking ahead","location","localization","local setting","literature survey","literature sources","literature selection","literature search and results","literature search / results","literature reviewed","literature review and discussion","literature review / discussion","literature review","literature findings","literature","list of abbreviations","linking evidence to action","limits","limiti della ricerca","limitations of the study","limitations of study","limitations and reasons for caution","limitations and reason for caution","limitations and implications","limitations and conclusions","limitations and conclusion","limitations / reasons for caution","limitations / reason for caution","limitations / implications","limitations / conclusions","limitations / conclusion","limitations","limitation","limitaciones","limitacao","life cycle","levels of evidence","level of proof","level of incidence","level of eviedence","level of evidence v","level of evidence iv","level of evidence iii","level of evidence ii","level of evidence i","level of evidence","level of evedience","level of clinical evidence","lessons learnt","lessons learned","lessons and messages","lessons / messages","lessons","lesson learnt","lesson","lernziele","lernziel","lern agenda","leistungsfahigkeit","learning points","learning point","learning outcomes","learning objectives","learning objective","learner outcomes","lay summary","lay abstract","latest findings","laboratory tests","laboratory studies","laboratory investigations","laboratory findings","laboratory data","laboratory analysis","kısıtlılıklar","kurzuberblick","kurzdarstellungen","kurzdarstellung","kurzbeschreibungen","kurzbeschreibung","kovetkeztetesek","kovetkeztetes","kontraindikationen","kontraindikation","konkluzio","konklusjoner","konklusjon","konklusion","komplikationen","knowledge translation","knowledge base","klinisches / methodisches problem","klinisches","kliniken","klinik","klassifikationen","klassifikation","kind of study","keys to success","keypoints","keynote address","key study factor","key results and conclusions","key results / conclusions","key results","key result","key recommendations","key questions and answers","key questions / answers","key questions","key practitioner messages","key practitioner message","key points","key messages and implications","key messages / implications","key messages","key message","key measures for improvement","key measures","key measure for improvement","key limitations","key learning points","key learning point","key issues","key issue","key findings and implications","key findings / implications","key findings","key finding","key conclusions and implications for practise","key conclusions and implications for practice","key conclusions and implications","key conclusions and implication for practice","key conclusions and clinical implications","key conclusions / implications for practise","key conclusions / implications for practice","key conclusions / implications","key conclusions / implication for practice","key conclusions / clinical implications","key conclusions","key conclusion and implications for practice","key conclusion / implications for practice","key conclusion","kesimpulan","kernpunten","kernpunkte","kernboodschappen","katılımcıların","katılımcılar","kasuistiken","kasuistik","justificativa e objetivos","justificativa / objetivos","justificativa","justification","justificacion del problema","justificacion de la investigacion","justificacion","johtopaatokset","it was concluded that","it is concluded that","issues addressed","issues","issue addressed","issue","isolates","investigations and treatment","investigations and diagnosis","investigations and diagnoses","investigations / treatment","investigations / diagnosis","investigations / diagnoses","investigations","investigation and diagnosis","investigation and diagnoses","investigation / diagnosis","investigation / diagnoses","investigation","investigated subjects","investigated group and methods","investigated group / methods","investigated group","inventions","introduzione e obiettivi","introduzione","introduktion och bakgrund","introduktion / bakgrund","introduktion","introduction et objectifs","introduction et objectif","introduction and objectives","introduction and objective","introduction and hypothesis","introduction and hypotheses","introduction / objectives","introduction / objective","introduction / objectifs","introduction / objectif","introduction / hypothesis","introduction / hypotheses","introduction","introductie","introducion","introduccion y objetivos","introduccion y objetivo","introduccion / objetivos","introduccion / objetivo","introduccion","introduccion","introducao e objetivos","introducao e objetivo","introducao e objectivos","introducao / objetivos","introducao / objetivo","introducao / objectivos","introducao","interventions and testing","interventions and techniques","interventions and technique","interventions and results","interventions and participants","interventions and outcomes","interventions and outcome measures","interventions and outcome","interventions and methods","interventions and measures","interventions and measurements","interventions and main results","interventions and main outcome measures","interventions and main outcome measurements","interventions and main measurements","interventions and exposures","interventions and exposure","interventions / testing","interventions / techniques","interventions / technique","interventions / results","interventions / participants","interventions / outcomes","interventions / outcome measures","interventions / outcome","interventions / methods","interventions / measures","interventions / measurements","interventions / main results","interventions / main outcome measures","interventions / main outcome measurements","interventions / main measurements","interventions / exposures","interventions / exposure","interventions","intervention or technique","intervention or exposure","intervention esd main outcome measurements","intervention and testing","intervention and techniques","intervention and technique","intervention and results","intervention and participants","intervention and outcomes","intervention and outcome measures","intervention and outcome measure","intervention and outcome","intervention and methods","intervention and measurements","intervention and main results","intervention and main outcome measures","intervention and main outcome measure","intervention and exposures","intervention and exposure","intervention / testing","intervention / techniques","intervention / technique","intervention / results","intervention / participants","intervention / outcomes","intervention / outcome measures","intervention / outcome measure","intervention / outcome","intervention / methods","intervention / measurements","intervention / main results","intervention / main outcome measures","intervention / main outcome measure","intervention / exposures","intervention / exposure","intervention","interventi","intervencoes","intervenciones","intervencion","intervencao","interpretations and conclusions","interpretations and conclusion","interpretations / conclusions","interpretations / conclusion","interpretations","interpretation of results","interpretation and conclusions","interpretation and conclusion","interpretation / conclusions","interpretation / conclusion","interpretation","interim results","interactions","intepretation","intention","integrative significance","integration","instruments and methods","instruments / methods","instruments","instrumentos","instrumento","instrumentation","instrument","institutions","institution","input","innovations","innovation and implications","innovation and conclusions","innovation and conclusion","innovation / implications","innovation / conclusions","innovation / conclusion","innovation","inleiding","inledning","injury patterns","initial assessment","informe del caso","information sources","information dissemination","information and discussions","information and discussion","information / discussions","information / discussion","information","informants","inferences","inference","infections","infection process","infection in cats","infection","infants and methods","infants / methods","individuals and methods","individuals / methods","individuals","individual coverage stable","indikationen","indikation","indications for use","indications","indication","index tests","index test","independent variables","independent variable","inclusion criteria","inclusion and exclusion criteria","inclusion / exclusion criteria","inclusion","included studies","incidence","in vivo study","in vivo studies","in vivo","in vitro studies","in vitro results","in the future","in summary","in practice","in conclusions","in conclusion","in clinical practice","important findings","importancia del proyecto","importancia","importance of the conclusions","importance","implikation","implikacje praktyczne","implicazioni pratiche","implications statement","implications of the hypothesis","implications of key findings","implications of hypothesis","implications of cancer survivors","implications for survivors","implications for research and practice","implications for research / practice","implications for research","implications for rehabilitation","implications for public health practice","implications for public health","implications for practise","implications for practice or research","implications for practice and research","implications for practice and policy","implications for practice and education","implications for practice / research","implications for practice / policy","implications for practice / education","implications for practice","implications for policy and research","implications for policy / research","implications for policy","implications for patient care","implications for nursing research","implications for nursing practice","implications for nursing managers","implications for nursing management","implications for nursing leadership","implications for nursing and health policy","implications for nursing / health policy","implications for nursing","implications for nurses","implications for nurse managers","implications for nurse management","implications for human medicine","implications for health policy formulation","implications for health policy and research","implications for health policy / research","implications for health policy","implications for health policies","implications for health care provision and use","implications for health care provision and policies","implications for health care provision / use","implications for health care provision / policies","implications for health care provision","implications for health care policy formulation","implications for future research","implications for further research","implications for education","implications for cm practice","implications for clinical practice","implications for case management practice","implications for case management","implications for cancer survivors","implications and contribution","implications and conclusions","implications and conclusion","implications and action","implications / contribution","implications / conclusions","implications / conclusion","implications / action","implications","implication statement","implication of the hypothesis","implication of key findings","implication for research","implication for practice","implication for nursing practice","implication for nursing management","implication for nursing and health policy","implication for nursing / health policy","implication for nursing","implication for nurse managers","implication for health policies","implication for health care provision and use","implication for health care provision / use","implication for further research","implication for case management practice","implication for cancer survivors","implication","implicacoes","implicaciones clinicas","implementation and results","implementation and performances","implementation and performance","implementation / results","implementation / performances","implementation / performance","implementation","impactos","impact to industry","impact statement","impact on traffic safety","impact on the industry","impact on industry","impact of the study","impact of study","impact of industry","impact for human medicine","impact and significance of the study","impact / significance of the study","impact","imaging findings","imaging","illustrative cases","illustrative case","identification","hypothesis and purposes","hypothesis and purpose","hypothesis and objectives","hypothesis and objective","hypothesis and methods","hypothesis and introduction","hypothesis and aims","hypothesis / purposes","hypothesis / purpose","hypothesis / objectives","hypothesis / objective","hypothesis / methods","hypothesis / introduction","hypothesis / aims","hypothesis","hypotheses and purposes","hypotheses and purpose","hypotheses and objectives","hypotheses and objective","hypotheses and introduction","hypotheses / purposes","hypotheses / purpose","hypotheses / objectives","hypotheses / objective","hypotheses / introduction","hypotheses","hypothesen","hypothese","hypertension","huvudresultat","huvudkategorierna","huvudfragor","huvudfragestallning","huvudfraga","human studies","human data synthesis","how this might change clinical practice","how the research was conducted","hosts","host range","hospital course","history and signs","history and findings","history and clinical findings","history and admission findings","history / signs","history / findings","history / clinical findings","history / admission findings","history","historico","historical perspective","historical context","historical background","historical","histopathology","histopathological findings","histopathologic findings","histology","histologie","hipotesis y objetivos","hipotesis y objetivo","hipotesis / objetivos","hipotesis / objetivo","hipotesis","hipotesi","hipoteses de cabimento","hipoteses","hipotese diagnostica","hipotese","hipertensao","hinweise","hinweis","hintergund","hintergrunden und zielstellungen","hintergrunden und zielstellung","hintergrunden und ziele","hintergrunden und ziel","hintergrunden und fragestellungen","hintergrunden und fragestellung","hintergrunden / zielstellungen","hintergrunden / zielstellung","hintergrunden / ziele","hintergrunden / ziel","hintergrunden / fragestellungen","hintergrunden / fragestellung","hintergrund und zielstellungen","hintergrund und zielstellung","hintergrund und ziele","hintergrund und ziel","hintergrund und fragestellungen","hintergrund und fragestellung","hintergrund / zielstellungen","hintergrund / zielstellung","hintergrund / ziele","hintergrund / ziel","hintergrund / fragestellungen","hintergrund / fragestellung","hintergrund","highlights","highlight","hensikten","hensikt","health economic evaluation","headline results","hatterrel es celok","hatterrel es celkituzesek","hatterrel es celkituzes","hatterrel es cel","hatterrel / celok","hatterrel / celkituzesek","hatterrel / celkituzes","hatterrel / cel","hatter es celok","hatter es celkituzesek","hatter es celkituzes","hatter es cel","hatter / celok","hatter / celkituzesek","hatter / celkituzes","hatter / cel","hatter","hasil penelitian","hasil dan pembahasan","hasil / pembahasan","hasil","harms","hallazgos","habitat and ecology","habitat / ecology","guidelines","guideline development","guideline","grupo control","grundlagen","grundlage","growth","growing points and areas timely for developing research","growing points / areas timely for developing research","growing points","group of patients and methods","group of patients / methods","group of patients","group b","group and methods","group / methods","graphical abstract","goals of work","goals of this study","goals of the study","goals and methods","goals / methods","goals","goal of this study","goal of the study","goal of study","goal and methods","goal / methods","goal","global importance","girisimler","girisim","giris ve amaclar","giris ve amac","giris / amaclar","giris / amac","giris","gerecler ve yontemler","gerecler ve yontem","gerecler / yontemler","gerecler / yontem","gerec ve yontemler","gerec ve yontem","gerec / yontemler","gerec / yontem","gerec","geographical distribution","genomforande","genome","genetics","genetic toxicology","genetic studies","genetic factors","genetic counseling","genetic analysis","generalizability to other populations","generalisability to other populations","general significance","general question","general methods","general measures","gegenstand und ziel","føremal","future work","future research needs topics","future research","future prospects and projects","future prospects / projects","future prospects","future prospect and projects","future prospect / projects","future plans","future perspectives","future directions","future direction","future challenges","future and projects","future / projects","future","further information","further developments","fundamentos y objetivos","fundamentos y objetivo","fundamentos teoricos","fundamentos / objetivos","fundamentos / objetivo","fundamentos","fundamento y objetivos","fundamento y objetivo","fundamento / objetivos","fundamento / objetivo","fundamentacion","fundamentacao teorica","fundamentacao","functional results","from a therapeutic point of view","from a technical point of view","from a clinical point of view","framework","fragestellungen und ziele","fragestellungen und ziel","fragestellungen und hintergrunden","fragestellungen und hintergrund","fragestellungen / ziele","fragestellungen / ziel","fragestellungen / hintergrunden","fragestellungen / hintergrund","fragestellungen","fragestellung und ziele","fragestellung und ziel","fragestellung und hintergrunden","fragestellung und hintergrund","fragestellung / ziele","fragestellung / ziel","fragestellung / hintergrunden","fragestellung / hintergrund","fragestellung","fragestelling und ziel","fragestallningar","fragestallning","fortsatt forskning","forslag till vidare studier","forslag till vidare forskning","forslag till framtida forskning","forslag till fortsatta studier","forslag till fortsatt forskning","forslag pa fortsatt forskning","forskningsfragor","forschungsfrage","formulacion del problema","formal","forma do estudo","foram utilizados os instrumentos","foram utilizados os descritores","fontes pesquisadas","fontes dos dados","fonte de fomento","followup","follow up","folgerungen","folgerung","focused question","focused clinical question","focus","first case","findings and results","findings and recommendations","findings and practice implications","findings and outcomes","findings and outcome","findings and key conclusions","findings and interpretation","findings and implications","findings and discussion","findings and conclusions","findings and conclusion","findings / results","findings / recommendations","findings / practice implications","findings / outcomes","findings / outcome","findings / originality / value","findings / key conclusions","findings / interpretation","findings / implications","findings / discussion","findings / conclusions","findings / conclusion","findings","finding","final remarks","final reflections","final diagnosis","final considerations","feedback","features","feasibility","fazit","fallvorstellung","falldarstellung","fallbeschreibungen","fallbeschreibung","fallberichte","fallbericht","fallbeispiele","fallbeispiel","factors","factores claves de exito","factor","facility","extraction of data","extraction methods","extraction","extraccion de datos","exposures","exposure measures","exposure measurement","exposure measure","exposure","expert opinion","expert clinical opinion","experiments and results","experiments / results","experiments","experimental variables","experimental variable","experimental subjects","experimental study","experimental studies","experimental setup","experimental results","experimental protocol","experimental procedures","experimental procedure","experimental preparations","experimental plan","experimental model","experimental methods","experimental method","experimental materials","experimental material","experimental interventions","experimental intervention","experimental designs","experimental design and setting","experimental design and results","experimental design and main outcome measures","experimental design / setting","experimental design / results","experimental design / main outcome measures","experimental design","experimental data","experimental conditions","experimental approaches","experimental approach and key results","experimental approach / key results","experimental approach","experimental animals","experimental","experiment design","experiment approach","experiment","experiences","experience to date","experience report","experience and results","experience / results","experience","expected results","expected outcomes","exegesis","exegese","exclusions","exclusion criteria","exclusion","exclusao do socio","examples","example","examinees and methods","examinees / methods","examinees","examinations","examination","evolution","evolucion clinica","evolucion","evolucao historica","evolucao","evidence synthesis","evidence summary","evidence review","evidence quality rating","evidence level","evidence base","evidence and values","evidence and information sources","evidence and consensus process","evidence acquisitions","evidence acquisition and synthesis","evidence acquisition / synthesis","evidence acquisition","evidence / values","evidence / information sources","evidence / consensus process","evidence","evaluations","evaluation results","evaluation of the hypothesis","evaluation methods","evaluation method","evaluation mechanisms","evaluation design","evaluation","evaluatie","evaluacion","eudract number","etiology","etiologic factors","etiologia","ethnopharmacological relevance","ethics and dissemination","ethics / dissemination","ethics","ethical issues and approval","ethical issues / approval","ethical issues","ethical considerations","ethical consideration","ethical approval","estudio transversal","estudio retrospectivo","estudio prospectivo de intervencion","estudio observacional descriptivo","estudio","estrategia de pesquisa","estrategia de busqueda","estado da arte","estadistica","essential results","escenario","erythropoietin therapy","ergebnisse und schlussfolgerungen","ergebnisse und schlussfolgerung","ergebnisse und diskussionen","ergebnisse und diskussion","ergebnisse / schlussfolgerungen","ergebnisse / schlussfolgerung","ergebnisse / diskussioner","ergebnisse / diskussionen","ergebnisse / diskussion","ergebnisse","ergebnis und schlussfolgerungen","ergebnis und schlussfolgerung","ergebnis und diskussionen","ergebnis und diskussion","ergebnis / schlussfolgerungen","ergebnis / schlussfolgerung","ergebnis / diskussionen","ergebnis / diskussion","ergebnis","eredmenyeket es kovetkezteteseket","eredmenyeket / kovetkezteteseket","eredmenyek es kovetkeztetesek","eredmenyek / kovetkeztetesek","eredmenyek","eredmeny es kovetkeztetes","eredmeny / kovetkeztetes","equipment and methods","equipment / methods","equipment","epilogue","epidemiology","epidemiologie","environment","entwicklungen","entwicklung","enrollment","enquadramento teorico","enquadramento","endpoints and linkages to other data","endpoints / linkages to other data","endpoints","endpoint","end points","end point","emplazamiento","emplacement","empirisk grund","empirical data","empirical application","empiri","empfehlungen","empfehlung fur die praxis","emerging knowledges","emerging areas for developing research","embryology","elmeleti hatter","eligibility criteria for selecting studies","eligibility criteria for included studies","eligibility criteria","eligibility","elements of a successful system","el problema","einschatzungen","einschatzung","einleitungen und zielstellungen","einleitungen und zielstellung","einleitungen / zielstellungen","einleitungen / zielstellung","einleitungen","einleitung und zielstellungen","einleitung und zielstellung","einleitung / zielstellungen","einleitung / zielstellung","einleitung","einfuhrungen","einfuhrung","efficacy","effects of the change","effects of change","effects of antihypertensive drugs","effects / changes","effects","effectiveness","effect of change","educational objectives","education","economic importance","economic analysis","ebm rating","early results","duration","drugs","drug therapy","dosage","donors and methods","donors / methods","donors","donnees","doelstelling","doel","distribution","disseny","dissemination and ethics","dissemination / ethics","dissemination","disscussion","diskussioner och slutsatser","diskussioner / slutsatser","diskussionen und schlussfolgerungen","diskussionen und schlussfolgerung","diskussionen / schlussfolgerungen","diskussionen / schlussfolgerung","diskussionen","diskussion und schlussfolgerungen","diskussion und schlussfolgerung","diskussion och slutsatser","diskussion och slutsats","diskussion / slutsatser","diskussion / slutsats","diskussion / schlussfolgerungen","diskussion / schlussfolgerung","diskussion","diskusjon","diseno metodologico","diseno del estudio","diseno de investigacion","diseno de estudio","diseno / metodologia / enfoque","diseno / metodologia","diseno","disegno","disease symptoms","disease signs","disease name and synonyms","disease name / synonyms","disease management","disease control","discusssion","discussoes e conclusoes","discussoes e conclusao","discussoes / conclusoes","discussoes / conclusao","discussoes","discussions et conclusions","discussions et conclusion","discussions and summary","discussions and summaries","discussions and impact","discussions and evaluation","discussions and conclusions","discussions and conclusion","discussions / summary","discussions / summaries","discussions / impact","discussions / evaluation","discussions / conclusions","discussions / conclusion","discussions","discussioni e conclusioni","discussioni e conclusione","discussioni / conclusioni","discussioni / conclusione","discussione generale","discussione e conclusioni","discussione e conclusione","discussione / conclusioni","discussione / conclusione","discussione","discussion et conclusions","discussion et conclusion","discussion conclusion","discussion and summary","discussion and summaries","discussion and results","discussion and recommendations","discussion and limitations","discussion and implications for practice","discussion and implications","discussion and impact on industry","discussion and impact","discussion and evaluation","discussion and conclusions","discussion and conclusion","discussion and clinical relevance","discussion and clinical implications","discussion / summary","discussion / summaries","discussion / results","discussion / recommendations","discussion / limitations","discussion / implications for practice","discussion / implications","discussion / impact on industry","discussion / impact","discussion / evaluation","discussion / conclusions","discussion / conclusion","discussion / clinical relevance","discussion / clinical implications","discussion","discussies en conclusies","discussies en conclusie","discussies / conclusies","discussies / conclusie","discussie en conclusies","discussie en conclusie","discussie / conclusies","discussie / conclusie","discussie","discussao e conclusoes","discussao e conclusao","discussao dos resultados","discussao / conclusoes","discussao / conclusao","discussao","discusiones y conclusiones","discusiones y conclusion","discusiones / conclusiones","discusiones / conclusion","discusion y conclusiones","discusion y conclusion","discusion / conclusiones","discusion / conclusion","discusion","disclosures","disclosure","disclaimer","differential diagnosis","differences in rollover amounts","differences in account balances","diets","dicussion","diagnostiken","diagnostik","diagnostics and therapy","diagnostics / therapy","diagnostics","diagnostico","diagnostic tests","diagnostic test","diagnostic procedures","diagnostic problems","diagnostic methods","diagnostic findings and therapy","diagnostic findings / therapy","diagnostic criteria","diagnostic","diagnosis and treatments","diagnosis and treatment","diagnosis and therapy","diagnosis and therapies","diagnosis and management","diagnosis and differential diagnosis","diagnosis and course","diagnosis and clinical course","diagnosis / treatments","diagnosis / treatment","diagnosis / therapy","diagnosis / therapies","diagnosis / management","diagnosis / differential diagnosis","diagnosis / course","diagnosis / clinical course","diagnosis","diagnoses and treatments","diagnoses and treatment","diagnoses and therapy","diagnoses and therapies","diagnoses and management","diagnoses / treatments","diagnoses / treatment","diagnoses / therapy","diagnoses / therapies","diagnoses / management","diagnose","devices","developments and conclusions","developments and conclusion","developments / conclusions","developments / conclusion","development and conclusions","development and conclusion","development / conclusions","development / conclusion","development","developing recommendations","determinations","details of the case","desing","designs and subjects","designs and subject","designs and settings","designs and setting","designs and scope","designs and sampling","designs and samples","designs and sample","designs and procedures","designs and procedure","designs and populations","designs and population","designs and perspectives","designs and perspective","designs and patients","designs and patient","designs and objectives","designs and objective","designs and methods","designs and methodology","designs and methodologies","designs and method","designs and measures","designs and measurements","designs and measurement","designs and materials","designs and material","designs and locations","designs and location","designs and interventions","designs and intervention","designs and data","designs and approaches","designs and approach","designs and analysis","designs and analyses","designs / subjects","designs / subject","designs / settings","designs / setting","designs / scope","designs / sampling","designs / samples","designs / sample","designs / procedures","designs / procedure","designs / populations","designs / population","designs / perspectives","designs / perspective","designs / patients","designs / patient","designs / objectives","designs / objective","designs / methods","designs / methodology / approach","designs / methodology","designs / methodologies","designs / method","designs / measures","designs / measurements","designs / measurement","designs / materials","designs / material","designs / locations","designs / location","designs / interventions","designs / intervention","designs / data","designs / approaches","designs / approach","designs / analysis","designs / analyses","designs","design study","design settings and participants","design settings / participants","design setting participants and measurements","design setting participants / measurements","design setting and subjects","design setting and patients","design setting and participants","design setting / subjects","design setting / patients","design setting / participants","design setting","design patients and measurements","design patients / measurements","design of the study","design of study","design och metoder","design och metod","design methods","design methodology / approach","design in vivo","design in vitro","design classification","design and volunteers","design and type of participants","design and subjects","design and subject","design and study subjects","design and study sample","design and study population","design and study participants","design and statistical analysis","design and specimens","design and settings","design and setting","design and scope","design and sampling","design and samples","design and sample","design and review methods","design and results","design and procedures","design and procedure","design and populations","design and population","design and perspectives","design and perspective","design and patients","design and patient","design and participants","design and outcomes","design and outcome measures","design and objectives","design and objective","design and methods","design and methodology","design and methodologies","design and method","design and measures","design and measurements","design and measurement","design and materials and methods","design and materials","design and material","design and main outcome measures","design and main outcome measure","design and locations","design and location","design and interventions","design and intervention","design and experimental material","design and data sources","design and data source","design and data","design and approaches","design and approach","design and analysis","design and analyses","design / volunteers","design / type of participants","design / subjects","design / subject","design / study subjects","design / study sample","design / study population","design / study participants","design / statistical analysis","design / specimens","design / settings","design / setting / patients","design / setting / participants / intervention","design / setting / participants","design / setting","design / scope","design / sampling","design / samples","design / sample","design / review methods","design / results","design / procedures","design / procedure","design / populations","design / population","design / perspectives","design / perspective","design / patients","design / patient","design / participants / measurements","design / participants / intervention","design / participants","design / outcomes","design / outcome measures","design / objectives","design / objective","design / metoder","design / metod","design / methology / approach","design / methods / approach","design / methods","design / methodologyapproach","design / methodology / value","design / methodology / research","design / methodology / aproach","design / methodology / approaches","design / methodology / approach","design / methodology / approac","design / methodology / appraoch","design / methodology / apporach","design / methodology / approach","design / methodology","design / methodologies / approach","design / methodologies","design / methodological / approach","design / methodolgy / approach","design / methododology / approach","design / methodlogy / approach","design / method / approach","design / method","design / methdology / approach","design / methdodology / approach","design / measures","design / measurements","design / measurement","design / materials and methods","design / materials / methods","design / materials","design / material","design / main outcome measures","design / main outcome measure","design / locations","design / location","design / interventions","design / intervention","design / experimental material","design / data sources","design / data source","design / data","design / approaches","design / approach / methodology","design / approach","design / analysis","design / analyses","design metoder","design / methodology / approach","design","desfechos","desenvolvimento","desenhos / metodologia / enfoque","desenho do estudo","desenho / metodologia / enfoque","descriptors","descriptor","descriptions","description of topic with related evidence","description of the technique","description of the system","description of the study","description of the project","description of the process","description of the intervention","description of the herd","description of the case","description of technique","description of systems","description of system","description of study","description of strategy","description of project","description of program","description of policy practice","description of intervention","description of instrumentation","description of device","description of cases","description of case","description of care practice","descripcion del problema","descripcion del caso","descripcion de los casos","descripcion de la muestra","descricao metodologica","descricao da experiencia","descricao","desarrollos y conclusiones","desarrollos y conclusion","desarrollos / conclusiones","desarrollos / conclusion","desarrollo y conclusiones","desarrollo y conclusion","desarrollo teorico","desarrollo / conclusiones","desarrollo / conclusion","desarrollo","dependent variables","dependent variable","dependent measures","demographics","demographic data","delineamento","delimitacion del problema","definitions","definition of the problem","definition","definicion del problema","definicion","declaration of interest","declaration of competing interests","debates","debate","datenlage","date sources","datasources","datainsamling och analys","datainsamling / analys","databases used","databases and data treatment","databases / data treatment","databases","database used","data synthesis and results","data synthesis and findings","data synthesis and conclusions","data synthesis and conclusion","data synthesis and analysis","data synthesis / results","data synthesis / findings","data synthesis / conclusions","data synthesis / conclusion","data synthesis / analysis","data synthesis","data syntheses","data summary","data sources and synthesis","data sources and study setting","data sources and study selection","data sources and study eligibility criteria","data sources and study design","data sources and settings","data sources and setting","data sources and selection criteria","data sources and selection","data sources and review methods","data sources and methods","data sources and extraction","data sources and eligibility","data sources and design","data sources and data extraction","data sources and collection","data sources / synthesis","data sources / study setting","data sources / study selection","data sources / study eligibility criteria","data sources / study design","data sources / settings","data sources / setting","data sources / selection criteria","data sources / selection","data sources / review methods","data sources / methods","data sources / extraction","data sources / eligibility","data sources / design","data sources / data extraction","data sources / collection","data sources","data source and synthesis","data source and study setting","data source and study selection","data source and selection","data source and methods","data source and extraction","data source / synthesis","data source / study setting","data source / study selection","data source / selection","data source / methods","data source / extraction","data source","data set","data selection and extraction","data selection and data extraction","data selection / extraction","data selection / data extraction","data selection","data retrieval","data resources","data quality","data identification and selection","data identification / selection","data identification","data gaps and recommendations","data gaps / recommendations","data extractions","data extraction methods","data extraction method","data extraction and synthesis","data extraction and study selection","data extraction and quality assessment","data extraction and data synthesis","data extraction and analysis","data extraction / synthesis","data extraction / study selection","data extraction / quality assessment","data extraction / data synthesis","data extraction / analysis","data extraction","data evaluation","data deposition","data collection methods","data collection method","data collection and synthesis","data collection and extraction","data collection and analysis","data collection and analyses","data collection / synthesis","data collection / extraction","data collection / analysis","data collection / analyses","data collection","data collected","data capture","data and sources","data and samples","data and sample","data and methods","data and method","data and measures","data analyzed","data analysis methods","data analysis method","data analysis","data analyses","data acquisition","data abstraction","data / sources","data / samples","data / sample","data / methods","data / method","data / measures","data","curriculum","current status","current situation and salient points","current situation / salient points","current situation","current research","current recommendations","current knowledge and key points","current knowledge and key point","current knowledge / key points","current knowledge / key point","current knowledge","current data","current controlled trials","cultural aspects","critique","critical summary assessment","critical issues and future directions","critical issues / future directions","critical issues","criterios inclusion","criterios de selecao","criterios de inclusion","criterios de inclusao","criterios de exclusion","criterios de exclusao","criteria for inclusion","criteria for considering studies for this review","criteria","credibility","course of therapy and results","course of therapy and result","course of therapy / results","course of therapy / result","course and treatments","course and treatment","course / treatments","course / treatment","count","couclusion","costs","corpus of evidence","coronary angiography","controversy","controversial issues","controls","control measures","control group","control","contributions and value add","contributions / value add","contributions","contribution to the field","contribution of the thesis","contribution and value add","contribution and value - add","contribution / value add","contribution / value - add","contribution","contribuicoes e originalidade","contribuicoes / originalidade","contribuicoes","contribuicao e originalidade","contribuicao / originalidade","contribuicao","contraindications","contraindication","contextualizacao","contexts and purposes","contexts and purpose","contexts and objectives","contexts and objective","contexts and backgrounds","contexts and background","contexts / purposes","contexts / purpose","contexts / objectives","contexts / objective","contexts / backgrounds","contexts / background","contexts","contexto","contextes et objectifs","contextes et objectif","contextes / objectifs","contextes / objectif","contexte et objectifs","contexte et objectif","contexte / objectifs","contexte / objectif","contexte","context of case","context and purposes","context and purpose","context and objectives","context and objective","context and backgrounds","context and background","context / purposes","context / purpose","context / objectives","context / objective","context / backgrounds","context / background","context","contesto e obiettivo","contents summary","contents","content organization","content analysis of literature","content","contact","construction and contents","construction and content","construction / contents","construction / content","construction","conslusions","conslusion","considerations","consideration","consideracoes preliminares","consideracoes iniciais","consideracoes gerais","consideracoes finais","consideracoes","consideraciones generales","consideraciones finales","consequences pour la pratique","consequences","consequence","consensus statement","consensus process","consensus position","consensus","conlusions","conlusion","conlcusions","conlcusion","conference process","conference participants","conelusion","condusions","condusion","conditions","condition","condensed abstract","condensation","concusions","concusion","conculusions","conculsions","conculsion","conclutions","conclution","conclusıons","conclusıon","conclustion","conclussion","conclusoes","conclusive","conclusios","conclusions significance","conclusions for practice","conclusions and suggestions","conclusions and significance of the study","conclusions and significance","conclusions and scientific significance","conclusions and revelance","conclusions and relevance to clinical practice","conclusions and relevance","conclusions and recommendations for clinical practice","conclusions and recommendations","conclusions and recommendation","conclusions and practice implications","conclusions and practical significance","conclusions and practical implications","conclusions and potential relevance","conclusions and policy implications","conclusions and perspectives","conclusions and perspective","conclusions and outlook","conclusions and methods","conclusions and method","conclusions and messages","conclusions and message","conclusions and limitations","conclusions and interpretations","conclusions and interpretation","conclusions and inferences","conclusions and implications of key findings","conclusions and implications for the practice","conclusions and implications for public health practice","conclusions and implications for practise","conclusions and implications for practice","conclusions and implications for nursing practice","conclusions and implications for nursing management","conclusions and implications for clinical practice","conclusions and implications for cancer survivors","conclusions and implications","conclusions and implication for practice","conclusions and implication","conclusions and impact","conclusions and hypothesis","conclusions and hypotheses","conclusions and general significance","conclusions and discussions","conclusions and discussion","conclusions and clinical significance","conclusions and clinical relevance","conclusions and clinical rehabilitation impact","conclusions and clinical importance","conclusions and clinical implications","conclusions and applications","conclusions and application","conclusions / suggestions","conclusions / significance of the study","conclusions / significance","conclusions / scientific significance","conclusions / revelance","conclusions / relevance to clinical practice","conclusions / relevance","conclusions / recommendations for clinical practice","conclusions / recommendations","conclusions / recommendation","conclusions / practice implications","conclusions / practical significance","conclusions / practical implications","conclusions / potential relevance","conclusions / policy implications","conclusions / perspectives","conclusions / perspective","conclusions / outlook","conclusions / methods","conclusions / method","conclusions / messages","conclusions / message","conclusions / limitations","conclusions / interpretations","conclusions / interpretation","conclusions / inferences","conclusions / implications of key findings","conclusions / implications for the practice","conclusions / implications for public health practice","conclusions / implications for practise","conclusions / implications for practice","conclusions / implications for nursing practice","conclusions / implications for nursing management","conclusions / implications for clinical practice","conclusions / implications for cancer survivors","conclusions / implications","conclusions / implication for practice","conclusions / implication","conclusions / impact","conclusions / hypothesis","conclusions / hypotheses","conclusions / general significance","conclusions / discussions","conclusions / discussion","conclusions / clinical significance","conclusions / clinical relevance","conclusions / clinical rehabilitation impact","conclusions / clinical importance","conclusions / clinical implications","conclusions / applications","conclusions / application","conclusions","conclusioni","conclusiones y recomendaciones","conclusiones y perspectivas","conclusiones generales","conclusiones finales","conclusiones / recomendaciones","conclusiones / perspectivas","conclusiones","conclusione","conclusiona","conclusion results","conclusion principal","conclusion and significance","conclusion and scientific significance","conclusion and relevance to clinical practice","conclusion and relevance","conclusion and recommendations","conclusion and recommendation","conclusion and practice implications","conclusion and practical implications","conclusion and potential relevance","conclusion and perspectives","conclusion and perspective","conclusion and outlook","conclusion and next steps","conclusion and methods","conclusion and method","conclusion and messages","conclusion and message","conclusion and interpretations","conclusion and interpretation","conclusion and implications of key findings","conclusion and implications for practice","conclusion and implications for nursing management","conclusion and implications for nursing and health policy","conclusion and implications for cancer survivors","conclusion and implications","conclusion and implication for practice","conclusion and implication","conclusion and impact","conclusion and hypothesis","conclusion and hypotheses","conclusion and general significance","conclusion and discussions","conclusion and discussion","conclusion and clinical significance","conclusion and clinical relevance","conclusion and clinical rehabilitation impact","conclusion and clinical importance","conclusion and clinical implications","conclusion and clinical implication","conclusion and applications","conclusion and application","conclusion General","conclusion / significance","conclusion / scientific significance","conclusion / relevance to clinical practice","conclusion / relevance","conclusion / recommendations","conclusion / recommendation","conclusion / practice implications","conclusion / practical implications","conclusion / potential relevance","conclusion / perspectives","conclusion / perspective","conclusion / outlook","conclusion / next steps","conclusion / methods","conclusion / method","conclusion / messages","conclusion / message","conclusion / interpretations","conclusion / interpretation","conclusion / implications of key findings","conclusion / implications for practice","conclusion / implications for nursing management","conclusion / implications for nursing / health policy","conclusion / implications for cancer survivors","conclusion / implications","conclusion / implication for practice","conclusion / implication","conclusion / impact","conclusion / hypothesis","conclusion / hypotheses","conclusion / general significance","conclusion / discussions","conclusion / discussion","conclusion / clinical significance","conclusion / clinical relevance","conclusion / clinical rehabilitation impact","conclusion / clinical importance","conclusion / clinical implications","conclusion / clinical implication","conclusion / applications","conclusion / application","conclusion","conclusio","conclusins","conclusies","conclusien","conclusie en discussie","conclusie / discussie","conclusie","conclusao","concluison","concluions","concluion","concluding statement","concluding remarks","concluding","concludendo","conclucions","conclsions","conclsion","conclision","conciusion","conceptual model","conceptual framework","concepts and trends","concepts / trends","concepts","conception","concept","composition of the committee","complications","complicaciones","completion date","competing interests","competencia tributaria","comparisons","comparison with other methods","comparison with existing methods","comparison with existing method","comparison","comparators","comorbidities","community context","comments and conclusions","comments and conclusion","comments / conclusions","comments / conclusion","commentary and conclusions","commentary and conclusion","commentary / conclusions","commentary / conclusion","commentary","commentaries","commentaires","comentarios finais","comentario","comclusion","coleta de dados","cohort selection","cohort","cocnlusions","cocnlusion","coclusions","coclusion","clinicaltrialsgov registration number","clinicaltrialsgov number","clinicaltrialsgov identifiers","clinicaltrialsgov identifier","clinicaltrialsgov id","clinicaltrialsgov","clinicaltrials gov identifier","clinicaltrials gov","clinicaltrials","clinicaltrial","clinical value","clinical use","clinical trialsgov identifier","clinical trials registry","clinical trials registration number","clinical trials registration information","clinical trials registration","clinical trials number","clinical trials information","clinical trials identifier","clinical trials gov identifier","clinical trials","clinical trial registry number","clinical trial registry information","clinical trial registry","clinical trial registration url","clinical trial registration number","clinical trial registration no","clinical trial registration nct numbers","clinical trial registration information","clinical trial registration","clinical trial reg no","clinical trial numbers","clinical trial number","clinical trial no","clinical trial information","clinical trial identifier","clinical trial","clinical symptoms","clinical subject and methods","clinical subject / methods","clinical study","clinical studies","clinical signs","clinical significance statement","clinical significance","clinical signifcance","clinical settings","clinical setting","clinical results","clinical reports","clinical report","clinical relevence","clinical relevance and conclusions","clinical relevance and conclusion","clinical relevance / conclusions","clinical relevance / conclusion","clinical relevance","clinical rehabilitation impact","clinical registration number","clinical record","clinical recommendations","clinical question","clinical procedure","clinical problem","clinical presentations","clinical presentation and interventions","clinical presentation and intervention","clinical presentation / interventions","clinical presentation / intervention","clinical presentation","clinical practice","clinical potential","clinical picture","clinical outcomes assessment","clinical outcomes","clinical outcome","clinical observations","clinical observation","clinical nursing implications","clinical need and target population","clinical need / target population","clinical need","clinical materials and methods","clinical materials / methods","clinical materials","clinical material and methods","clinical material / methods","clinical material","clinical manifestations","clinical management","clinical investigations","clinical importance","clinical implications","clinical implication","clinical history","clinical findings and treatment","clinical findings and diagnosis","clinical findings / treatment","clinical findings / diagnosis","clinical findings","clinical features","clinical feature","clinical experience","clinical example","clinical evidence","clinical evaluation","clinical diagnosis","clinical description","clinical decision making","clinical data","clinical course and therapy","clinical course / therapy","clinical course","clinical context","clinical considerations","clinical consequences","clinical characteristics","clinical challenges","clinical cases and conclusions","clinical cases and conclusion","clinical cases / conclusions","clinical cases / conclusion","clinical cases","clinical case report","clinical case and conclusions","clinical case and conclusion","clinical case / conclusions","clinical case / conclusion","clinical case","clinical care relevance","clinical bottom line","clinical assessment","clinical aspects","clinical applications","clinical application","clinical appearance","clinical and research advantages","clinical and pathological findings","clinical and laboratory tests","clinical and laboratory findings","clinical advantages","clinical advantage","clinical / research advantages","clinical / pathological findings","clinical / methodical issue","clinical / laboratory tests","clinical / laboratory findings","clinic case","clinic","classification of evidence","classification","cile prace","cile","choice of solution","children and methods","children / methods","chief outcome measures","chemotherapy","charakteristika","characteristics","challenges and successes","challenges and lessons learned","challenges / successes","challenges / lessons learned","challenges","celok","cell lines","celkituzes","cel pracy","cel badania","cel badan","cel","cconclusion","cautions","caution","causes","category","casus","casuistics and methods","casuistics and method","casuistics / methods","casuistics / method","casuistics","casuisticas e metodos","casuisticas e metodo","casuisticas / metodos","casuisticas / metodo","casuistica e metodos","casuistica e metodo","casuistica / metodos","casuistica / metodo","casuistica","casuistic and methods","casuistic and method","casuistic / methods","casuistic / method","casuistic","casos concretos","casos clinicos","casos clinico","casos","caso clinicos","caso clinico","caso","cases reports","cases report","cases presentation","cases description","cases and techniques","cases and technique","cases and reviews","cases and review","cases and results","cases and methods","cases and method","cases / techniques","cases / technique","cases / reviews","cases / review","cases / results","cases / methods","cases / method","cases","case summary","case summaries","case study","case studies","case series summary","case series description","case series","case review","case results","case result","case reports and discussion","case reports / discussion","case reports","case report and review","case report and results","case report and methods","case report and method","case report and literature review","case report and discussion","case report / review","case report / results","case report / methods","case report / method","case report / literature review","case report / discussion","case report","case record","case presentations","case presentation and intervention","case presentation / intervention","case presentation","case outlines","case outline","case outcome","case or series summary","case material","case management","case illustration","case hypothesis","case history","case histories","case experience","case examples","case example","case discussion","case diagnosis and treatment","case diagnosis / treatment","case diagnosis","case details","case descriptions","case description and techniques","case description and technique","case description and results","case description and outcomes","case description and methods","case description / techniques","case description / technique","case description / results","case description / outcomes","case description / methods","case description","case definition","case characteristics","case and techniques","case and technique","case and reviews","case and review","case and results","case and methods","case and method","case analysis","case / techniques","case / technique","case / series summary","case / reviews","case / review","case / results","case / methods","case / method","case ( description )","case","cas clinique","care reports","care report","cardiovascular risk","caracteristicas clinicas","caracteristicas","capsule summary","capsule abstract","capsule","campione","calculations","calculation","burden of suffering","bulgular","bu calısmanın amacı","brief description","breve historico","breve evolucao historica","breast feeding","brace description","bottom line","blood pressure variability","biological significance","biological mechanisms","bewertungen","bewertung","bevezetes","best practices","beschrijving","beschouwing","bersicht","benefits and harms","benefits / harms","benefits","behandlungen","behandlung","befunde","befund","beckground","basic science advances","basic research design and participants","basic research design / participants","basic research design","basic procedures","basic procedure","basic methods","basic design","bases de datos","baseline results","baseline data","base teorica","bakgrunn og hensikt","bakgrunn og formal","bakgrunn for valg av tema","bakgrunn for val av tema","bakgrunn / hensikt","bakgrunn / formal","bakgrunn","bakgrunder och syften","bakgrunder och syfte","bakgrunder och problemformuleringar","bakgrunder och problemformulering","bakgrunder och problemdiskussioner","bakgrunder och problemdiskussion","bakgrunder och problem","bakgrunder / syften","bakgrunder / syfte","bakgrunder / problemformuleringar","bakgrunder / problemformulering","bakgrunder / problemdiskussioner","bakgrunder / problemdiskussion","bakgrunder / problem","bakgrund till studien","bakgrund och syften","bakgrund och syfte","bakgrund och problemformuleringar","bakgrund och problemformulering","bakgrund och problemdiskussioner","bakgrund och problemdiskussion","bakgrund och problem","bakgrund / syften","bakgrund / syfte","bakgrund / problemformuleringar","bakgrund / problemformulering","bakgrund / problemdiskussioner","bakgrund / problemdiskussion","bakgrund / problem","bakgrund","baggrund","badani i metody","badani i metoda","badani / metody","badani / metoda","bacterial strains","backround","backgrounds and settings","backgrounds and setting","backgrounds and rationale","backgrounds and questions","backgrounds and question","backgrounds and purposes","backgrounds and purpose","backgrounds and objectives","backgrounds and objective","backgrounds and introduction","backgrounds and hypothesis","backgrounds and hypotheses","backgrounds and discussions","backgrounds and discussion","backgrounds and designs","backgrounds and design","backgrounds and contexts","backgrounds and context","backgrounds and contents","backgrounds and content","backgrounds and aims","backgrounds and aim","backgrounds / settings","backgrounds / setting","backgrounds / rationale","backgrounds / questions","backgrounds / question","backgrounds / purposes","backgrounds / purpose","backgrounds / objectives","backgrounds / objective","backgrounds / introduction","backgrounds / hypothesis","backgrounds / hypotheses","backgrounds / discussions","backgrounds / discussion","backgrounds / designs","backgrounds / design","backgrounds / contexts","backgrounds / context","backgrounds / contents","backgrounds / content","backgrounds / aims","backgrounds / aim","backgrounds","background information","background data","background and significance","background and settings","background and setting","background and rationale for the study","background and rationale","background and questions","background and question","background and purposes","background and purpose","background and problem discussion","background and objectives","background and objective","background and methods","background and introduction","background and hypothesis","background and hypotheses","background and discussions","background and discussion","background and designs","background and design","background and contexts","background and context","background and contents","background and content","background and aims","background and aim","background aim","background / significance","background / settings","background / setting","background / rationale","background / questions","background / question","background / purposes","background / purpose","background / problem discussion","background / objectives","background / objective","background / methods","background / introduction","background / hypothesis","background / hypotheses","background / discussions","background / discussion","background / designs","background / design","background / contexts","background / context","background / contents","background / content","background / aims","background / aim","background ( issue / problem )","background","backgroud / purpose","backgroud / aims","backgroud","backgrond","backgound","bacground","avgransningar","avgransning","avaliacao","availability and requirements","availability and implementation","availability / requirements","availability / implementation","availability","availabilities and implementation","availabilities / implementation","australian new zealand clinical trials registry","ausgangslage","ausblicke","ausblick","audit tool","audit","audience","atiologie","assessments","assessment tools","assessment of risk factors","assessment of problem","assessment","aspectos metodologicos","aspectos historicos","aspectos eticos","article selection","arguments","argumentos","argument","areas to develop research","areas timely for developing research","areas of controversy","areas of agreement and controversy","areas of agreement / controversy","areas of agreement","areas covered in this review","areas covered in the review","areas covered by this review","areas covered","area timely for developing research","area of study","area of controversy","area covered in this review","area covered","apresentacao do problema","apresentacao","approaches and methods","approaches and methodology","approaches and methodologies","approaches and method","approaches / methods","approaches / methodology","approaches / methodologies","approaches / method","approaches","approach and results","approach and methods","approach and methodology","approach and methodologies","approach and method","approach / results","approach / methods","approach / methodology","approach / methodologies","approach / method","approach","appraisal and synthesis methods","appraisal and synthesis","appraisal and syntheses","appraisal / synthesis methods","appraisal / synthesis","appraisal / syntheses","appraisal","applications and practices","applications and practice","applications and improvements","applications and improvement","applications / practices","applications / practice","applications / improvements","applications / improvement","applications","application to practice","application to clinical practice","application and practices","application and practice","application and improvements","application and improvement","application / practices","application / practice","application / improvements","application / improvement","application","applicability","apparatus","aportaciones y resultados","aportaciones y resultado","aportaciones / resultados","aportaciones / resultado","aportacion y resultados","aportacion y resultado","aportacion / resultados","aportacion / resultado","anticipations","anticipated results","antibiotic therapy","antesedentes","antecedents","antecedentes y objetivos","antecedentes y objetivo","antecedentes personales","antecedentes / objetivos","antecedentes / objetivo","antecedentes","antecedente y objetivos","antecedente y objetivo","antecedente / objetivos","antecedente / objetivo","antecedente","answers","answer to the question","answer","anotace","anliegen","animals used","animals studied and procedures","animals studied / procedures","animals studied","animals or sample population","animals and sample population","animals and procedures","animals and procedure","animals and methods","animals and method","animals and interventions","animals and design","animals / sample population","animals / procedures","animals / procedure","animals / methods","animals / method","animals / interventions","animals / design","animals","animal studies","animal studied","animal population","animal or sample population","animal model","anatomic study","anatomia patologica","analytical techniques","analytical methods","analytical approach","analytic strategy","analytic methods","analysis results","analysis of results","analysis and results","analysis and interpretations","analysis and interpretation","analysis / results","analysis / interpretations","analysis / interpretation","analysis","analyses performed","analyses and results","analyses and interpretations","analyses and interpretation","analyses / results","analyses / interpretations","analyses / interpretation","analyses","analyse statistique","analyse","analisis estadistico descriptivo","analisis estadistico","analisis del problema","analisis de resultados","analisis de datos","analisis conceptual","analisis comparativo","analisis","analise estatistica","analise estati","analise dos dados","analise","ana sonuc olcumleri","ana bulgular","ams subject classification","amostra","ambito","ambiente","amaclar","amac ve hipotez","amac","alternate approach","allocation","allergological tests","aims or focus of discussion","aims of this review","aims of the study","aims of the review","aims of the paper","aims of study","aims and scope","aims and results","aims and purpose","aims and objectives","aims and objective","aims and methods","aims and methodology","aims and methodologies","aims and method","aims and introduction","aims and hypothesis","aims and hypotheses","aims and goals","aims and goal","aims and development","aims and designs","aims and design","aims and conclusions","aims and conclusion","aims and backgrounds","aims and background","aims / scope","aims / results","aims / purpose","aims / objectives / purpose","aims / objectives","aims / objective / purpose","aims / objective","aims / methods","aims / methodology","aims / methodologies","aims / method","aims / introduction","aims / hypothesis","aims / hypotheses","aims / goals","aims / goal","aims / focus of discussion","aims / development","aims / designs","aims / design","aims / conclusions","aims / conclusion","aims / backgrounds","aims / background","aims","aim of work","aim of this work","aim of this study","aim of the work","aim of the study and methods","aim of the study / methods","aim of the study","aim of the review","aim of the research","aim of the present study","aim of the paper","aim of study","aim of review","aim of paper","aim of our study","aim background","aim and study design","aim and scope","aim and objectives","aim and objective","aim and methods","aim and methodology","aim and methodologies","aim and method","aim and hypothesis","aim and hypotheses","aim and goals","aim and goal","aim and designs","aim and design","aim and conclusions","aim and conclusion","aim and backgrounds","aim and background","aim / study design","aim / scope","aim / objectives / purpose","aim / objectives","aim / objective","aim / methods","aim / methodology","aim / methodologies","aim / method","aim / hypothesis","aim / hypotheses","aim / goals","aim / goal","aim / designs","aim / design","aim / conclusions","aim / conclusion","aim / backgrounds","aim / background","aim","aging in the community","aetiology","adverse events","adverse effects","advantages","advances in knowledge and implications for patient care","advances in knowledge / implications for patient care","advances in knowledge","advances","advance in knowledge","admission findings","activity","activities","actions taken","actions","action taken","action statements","action","acquisition of the evidence","acquisition of evidence","acquiring of evidence","acoes","acknowledgments","acknowledgment","acknowledgements","acknowledgement","achtergronden en doelstellingen","achtergronden en doelstelling","achtergronden / doelstellingen","achtergronden / doelstelling","achtergrond en doelstellingen","achtergrond en doelstelling","achtergrond / doelstellingen","achtergrond / doelstelling","achtergrond","achievements","achados","accme accreditation","accessible abstract","access to data","access to care","abstract in french","abstract","absicht","abbreviations","abbreviation","abbreviated description of the state of knowledge","abbreviated abstract","aanleiding","a case report","TULOKSET","Risultato e Conclusione","Risultato / Discussioni","Risultati e Discussioni","Risultati e Conclusione","Risultati / Discussioni","Resultats et Applications","Resultats et Application","Resultat et Applications","Resultat et Application","Racional e Objetivo","Racional / Objetivo","Racional","PAATELMAT","Onderzoek","Objectifs","Objectif","Obiettivo","Methodologie et resultats","Methodologie et resultat","Methodologie","Methodes et resultats","Methodes et resultat","Materiel et methodes","Materiel et Resultats","Materiel et Resultat","METODOLOGIA","MENETELMAT","LAHTOKOHDAT","Justificacion del tema","Introduzione e obiettivo","Fundamento","Forskningsfraga","Conclusion et recommandations","Conclusion et recommandation","Conclusion et perspectives","Conclusion et perspective","Conclusion et applications","Conclusion et application des resultats","Conclusion et application de la recherche","Conclusion et application","Conclusion et applicabilite des resultats","CONTEUDO"],"abstract_keywords":["한국어 초록","초록","중국어 초록","요 약 abstract","요 약","영어 초록","영문 초록","要約 — abstract","要約 · abstract","要約 | abstract","要約 summary","要約 abstract","要約 / abstract","要約 - abstract","要約 ( abstract )","要約","要旨 abstract","要旨","英文要約","英文要旨","英文摘要","研究概要","正文快照","概要 abstract","概要","期刊 核心期刊 QCode","期刊 核心期刊 ISSN","期刊 QCode","期刊 ISSN","撮要","摘要","提要","抄録","抄訳","和文抄録","会议 会议记录ID","中文摘要","中华医学会 QCode","中华医学会 ISSN","תקציר","сажетак","реферат abstract","реферат","резюме","анотація","аннотация","абстрактныи abstract","абстрактныи","абстрактные abstract","абстрактные","абстра ктныи abstract","абстра ктныи","Реэюме abstract","Реэюме","Περιληψη abstract","Περιληψη","Περιγραφη","zuzammenfassung","zussamnenfassung","zussammenfassung","zussamenfasung","zussamenfassung","zusmmenfassung","zusemmenfassung","zusanunenfassung","zusamnenfassung","zusammmenfassung","zusammfassung","zusammeunfassung","zusammentassung","zusammenstellung","zusammensfassung","zusammensetzung","zusammenlassung","zusammenjassung","zusammenhang","zusammengefaßt","zusammengassurg","zusammenfussung","zusammenfssung","zusammenfessung","zusammenfaßsung","zusammenfaussung","zusammenfasung","zusammenfassurg","zusammenfassungs","zusammenfassungen","zusammenfassung — summary","zusammenfassung — erluterung","zusammenfassung — erlauterung","zusammenfassung — abstract","zusammenfassung · summary","zusammenfassung · erluterung","zusammenfassung · erlauterung","zusammenfassung · abstract","zusammenfassung | summary","zusammenfassung | erluterung","zusammenfassung | erlauterung","zusammenfassung | abstract","zusammenfassung und summary","zusammenfassung und summary","zusammenfassung und abstrakt","zusammenfassung und abstrakt","zusammenfassung und abstract","zusammenfassung und abstract","zusammenfassung summary in german","zusammenfassung summary in german","zusammenfassung summary","zusammenfassung in german","zusammenfassung in german","zusammenfassung erluterung","zusammenfassung erlauterung","zusammenfassung englisch","zusammenfassung englisch","zusammenfassung and summary","zusammenfassung and summary","zusammenfassung and abstrakt","zusammenfassung and abstrakt","zusammenfassung and abstract","zusammenfassung and abstract","zusammenfassung abstract","zusammenfassung / summary","zusammenfassung / erluterung","zusammenfassung / erlauterung","zusammenfassung / abstract","zusammenfassung - summary","zusammenfassung - erluterung","zusammenfassung - erlauterung","zusammenfassung - abstract","zusammenfassung ( summary )","zusammenfassung ( erluterung )","zusammenfassung ( erlauterung )","zusammenfassung ( abstract )","zusammenfassung","zusammenfassun","zusammenfassug","zusammenfassu","zusammenfasssung","zusammenfassend","zusammenfassaung","zusammenfassaug","zusammenfaasung","zusammefassung","zusammanfasung","zusammanfassung","zusamenfassung","zusambenfassung","zumsammenfassung","zummenfassung","zudammenfassung","zuammenfassung","znsammenfassung","zasammenfassung","turkish abstract","technical abstract","synopsis","symmary","suummary","susammenfassung","surmmary","sunto","summury","summory","summmary","summery","summay","summary1","summary — zusammenfassung","summary — resume","summary — abstract","summary · zusammenfassung","summary · resume","summary · abstract","summary | zusammenfassung","summary | resume","summary | abstract","summary zusammenfassung","summary statement","summary resume francais","summary resume en francais","summary resume en deutsch","summary resume en anglais","summary resume deutsch","summary resume anglais","summary resume","summary or description","summary of the invention","summary francais","summary form only given .","summary en francais","summary en deutsch","summary en anglais","summary anglais","summary abstract","summary / zusammenfassung","summary / resume","summary / kata kunci","summary / abstract","summary - zusammenfassung","summary - resume","summary - abstract","summary , zusammenfassung","summary ( zusammenfassung )","summary ( resume )","summary ( abstract )","summary","summarium","summaries","summar","summaly","summaire abstract","summaire","summaey","summaby","summa . ry","sumary","sumarry","sumario abstract","sumario abstract","sumario","sumario","sumamry","sum . mary","suimmary","suhrn","su . mmary","streszczenie polskie","streszczenie angielskie","streszczenie","statement abstract","spanish abstract","souhrn","sommario","sommaire","smmary","semenvatting abstract","semenvatting","sazetak","santrauka","sammenvatting abstract","sammenvatting","sammenvating abstract","sammenvating","sammenfatning","sammendrag","sammary","sammanfattning","samevatting abstract","samevatting","samenvatting abstract","samenvatting","samenvating abstract","samenvating","riepilogo","riassunto","rezumat","reziume","resumos","resumo — summary","resumo — abstract","resumo · summary","resumo · abstract","resumo | summary","resumo | abstract","resumo y abstract","resumo summary","resumo expandido tecnico cientifico","resumo expandido","resumo executivo","resumo estendido","resumo em portugues","resumo em ingles","resumo e abstract","resumo dos artigos","resumo do artigo","resumo and abstract","resumo abstract resumen","resumo abstract","resumo : summary","resumo : abstract","resumo / summary","resumo / abstract","resumo / / summary","resumo / / abstract","resumo - summary","resumo - abstract","resumo ( summary )","resumo ( abstract )","resumo","resumet","resumes","resumes","resumen1","resumen — abstract","resumen – abstract","resumen · abstract","resumen · abstract","resumen | abstract","resumen | abstract","resumen y abstract","resumen summary","resumen spanisch","resumen o descripcion","resumen extendido","resumen estructurado","resumen esp","resumen en terminos sencillos","resumen en ingles","resumen en espanol","resumen e abstract","resumen del autor","resumen and abstract","resumen abstract","resumen / abstract","resumen / abstract","resumen / / abstract","resumen - abstract","resumen ( abstract )","resumen ( abstract )","resumen","resumen","resumee","resumee","resumee","resumee","resume — summary","resume – abstract","resume · summary","resume · abstract","resume | summary","resume | abstract","resume summary","resume franzosisch","resume francais","resume executif","resume et summary","resume et abstract","resume en francais","resume en arabe","resume en anglais","resume du rapport","resume du projet","resume du memoire","resume du livre","resume du document","resume de these","resume de la these","resume de l intrigue","resume de l etude","resume de l article","resume arabe","resume anglais","resume and abstract","resume abstract","resume abstract","resume / summary","resume / abstract","resume / / abstract","resume - summary","resume - abstract","resume - abstract","resume ( summary )","resume ( abstract )","resume & abstract","resume","resume","resume","resume","resume","resume","resume","resum","reasumea","re ' sume","r ’ esum ’ e","r . esumen","r . esume","r - esumo","pt summary","povzetek","portuguese abstract","polish abstract","podsumowanie abstract","podsumowanie","ozet — abstract","ozet · abstract","ozet | abstract","ozet turkce","ozet ingilizce","ozet abstract","ozet / abstract","ozet - abstract","ozet ( abstract )","ozet","ozet","oz yabancı","oz tr","oz","oz","opsomming","o ' zet","leeswijzer","kurzzusammenfassung","kurzreferat","kurzfassung auf englisch","kurzfassung auf deutsch","kurzfassung","kapitelzusammenfassung","italian abstract","interpretive summary","inhaltsubersicht","inhalts — ubersicht","inhalts · ubersicht","inhalts - ubersicht","ingilizce ozet summary","ingilizce ozet abstract","ingilizce ozet","german abstract","french abstract","fr summary","executive summary","estratto abstract","estratto","es summary","english abstract","en summary","dezusammenfassung","description — abstract","description · abstract","description | abstract","description abstract","description / abstract","description - abstract","description ( abstract )","de summary","cheminform abstract","bstract","auzug","auzsug","autoriassunto","auto riassunto","auszus","auszurg","auszung","auszuge","auszug","auszeg","auszag","ausuzg","ausazg","auazug","asuzug","astratto abstract","astratto","astract","asbtract","asbstract","asbract","arstract","anstract","abtsrcat","abtsract","abtracts","abtract","abszug","absyract","absuzug","abstsract","abstruct","abstrtact","abstrsct","abstrect","abstrct","abstrcat","abstrato","abstrat","abstrast","abstrasct","abstrakty","abstraktum abstract","abstraktum","abstrakt w j . dokumentu","abstrakt spanisch","abstrakt franzosisch","abstrakt englisch","abstrakt deutsch","abstrakt abstract","abstrakt","abstraksi","abstrak","abstrait","abstrahere","abstrafct","abstraet","abstrad","abstract⎯","abstracts — resumes","abstracts · resumes","abstracts | resumes","abstracts resumes","abstracts / resumes","abstracts - resumes","abstracts ( resumes )","abstract1","abstract — zusammenfassung","abstract — summary","abstract — summary","abstract — resumes","abstract — resume","abstract — english","abstract — eng","abstract — description","abstract — ozet","abstract · zusammenfassung","abstract · summary","abstract · summary","abstract · resumes","abstract · resume","abstract · ozet","abstract · description","abstract | zusammenfassung","abstract | summary","abstract | summary","abstract | resumes","abstract | resume","abstract | ozet","abstract | description","abstract zusammenfassung","abstract turkish","abstract summary","abstract summary","abstract resumo","abstract resumes","abstract resumen resume","abstract resumen","abstract resume","abstract ozet","abstract inglese","abstract in turkish","abstract in turkish","abstract in spanish","abstract in portuguese","abstract in polish","abstract in italian","abstract in inglese","abstract in german","abstract in french","abstract in english","abstract in english","abstract franzosisch","abstract english","abstract englisch","abstract eng","abstract deutsch","abstract description","abstract de","abstract abstrato","abstract abstrat","abstract = \"","abstract : english","abstract : eng","abstract / zusammenfassung","abstract / summary","abstract / summary","abstract / resumes","abstract / resume","abstract / ozet","abstract / description","abstract / aas wichtigste in kurze","abstract - zusammenfassung","abstract - summary","abstract - summary","abstract - resumes","abstract - resume","abstract - ozet","abstract - english","abstract - eng","abstract - description","abstract ( zusammenfassung )","abstract ( summary )","abstract ( summary )","abstract ( resumes )","abstract ( resume )","abstract ( ozet )","abstract ( ita )","abstract ( english )","abstract ( eng )","abstract ( description )","abstract","abstracrt","abstracr","abstracl","abstrack","abstracat","abstracao abstract","abstracao","abstrac ’ t","abstrac t","abstrac : t","abstrac ' t","abstrac \" t","abstrac","abstra ct","abstra . ct","abstra , ct","abstra ( ct","abstra ' ct","abstr act","abstr [ act","abstr . act","abstr . a . ct","abstr . a , ct","abstr - act","abstr , act","abstr ' act","absteract","absteact","abstbact","abstarcts","abstarct","abstaract","abstact","abst ract","abst ] ract","abst [ ract","abst : ract","abst . ract","abst - ract","abst , ract","abst ' ract","absstract","absrtract","absrtact","absrract","absract","absatrct","absatract","abs tract","abs . tract","abs - tract","abs , tract","abs ' tract","abs ' t ' ract","abrstact","abract","abatract","abastract","ab stract","ab , stract","ab ) stract","aabstract","a bstract","a b s t r a ct","a ] bstract","a . bstract","a - bstract","a , bstract","a ' bstract","[ resumen ]","[ abstract ]","TOM TAT","< resumen >","< abstract >","( resumen )","( abstract )"],"institution_words":["มหาวิทยาลัย","جامعة","אוניברסיטה","інституту","факультетом","факультета","факультет","факултет","університет","университетом","университета","университет","семинария","политехнического","политехническим","политехническии","ниц","нии","колледжем","колледжа","колегия","институтом","института","институт","πανεπιστημιου","πανεπιστημιο","κολλεγιο","yніверситету","yrkeshogskola","yrkeshogskola","yrkeshoegskola","yliopisto","wydział","wydziat","vishwavidyalya","vishwavidyalaya","vidyapith","vidyapeeth","usniversity","unviersity","unversity","unversidade","unuversity","uniwersytetu","uniwersytet","uniwerstet","uniwersity","uniweristy","univrsity","univresity","univiersity","univiersidad","univezitet","univetsity","univestsiy","univestity","univesity","univesite","univesitatsspital","univesita","univerziteta","univerzitet","univerzita","univerze","univerza","univerysity","universuty","universty","universtiy","universtity","universtitat","universiy","universitz","universityof","university","universitiy","universities","universiti","universiteti","universitetet","universitetas","universitetas","universitet","universitesi","universitesi","universites","universiteit","universite","universite","universitatsmedizin","universitatsinstitut","universitatea","universitate","universitate","universitate","universitat","universitat","universitas","universitario","universitaria","universitaria","universitaires","universitaet","universita","universita","universita","universit","universiry","universirty","universidae","universidade","universidad","universad","univerity","univerite","univeritas","univerita","univeristy","univerisity","univerisiti","univerdity","univercity","univerciti","univercidade","univeraity","univeersity","unive","univ","uniersity","unicersity","unibertsitatea","uneversity","ulikool","ulikool","uiversity","uinivesrity","uiniversity","uczelnia","tudomanyegyetem","tiedekunta","tehnikakorgkool","tecnologico","technische","tech","szkoła","sveuciliste","sveuciliste","skole","skola","skola","skola","skola","seminary","seminario","seminari","scuola","schule","school","privatuniversitat","polyteknisk","polytechnische","polytechnique","polytechnikum","polytechnic","polytech","politeknik","politehnica","politecnico","politecnico","politechnikai","politechnika","politechniczny","politechnice","politechnic","mahavidyalaya","lembaga","kолледж","kollegion","kolej","kolegium","kolegija","jardins","jardin","iнститут","istituto","istitute","intitute","intitute","interuniversity","instytut","instutute","instutut","instution","instute","instituute","instituut","institutul","institutt","instituto","institutionen","institution","institutet","institutes","institute","institut","institut","institue","institude","instittute","institiute","inst","insituto","insitute","insitut","insititute","insistute","inetituto","høgskolen","hogskolen","hogskolan","hogskola","hogeschool","hochschule","haskolann","haskola","gardens","garden","foiskola","fakultetet","fakulteta","fakultet","fakultesi","fakulte","fakulte","fakultat","fakultat","fakultas","fakulta","faculty","faculties","faculti","facultet","faculteit","faculte","facultade","facultad","faculdades","faculdade","facolta","facolta","fachhochschule","escuelas","escuela","escola","enstitusu","egyetem","ecole","ecole","conservatory","conservatorium","conservatoire","collegium","collegio","college","college","colegio"],"title":["title","título","titel","titre","titolo","otsikko","judul","başlık","başlik","titlu","títol","nadpis","tytuł","tytul","naslov","titula","titill","pealkiri","virsraksts","cim","tiêu đề","τίτλος","标题","標題","のタイトル","タイトル","제목","название","назва","заглавие","загаловак","наслов"],"toc_titles":["contents","list of boxes","list of figures","list of tables","table of contents","tabla de contenido","índice de contenido","inhaltsverzeichnis","table des matières","indice","innehållsförteckning","sisällysluettelo","indholdsfortegnelse","daftar isi","içindekiler","cuprins","taula de continguts","spis treści","inhoudsopgave","inhoudstafel","sadržaj","efnisyfirlit","sisukord","sisukord / contents","satura rādītājs","turinys","turinys / contents","kazalo","kazalo / contents","tartalomjegyzék","tartalomjegyzék / contents","mục lục","πίνακας περιεχομένων","目录","目錄","目次","목차","содержание","зміст","съдържание","садржај","змест"],"volume_words":["volume","vol.","vol","vo1.","vo1","vo.","vo","jahrgang","band","volumen","том","tom","tome","tomo","cilt"],"professor_titles":["prof","profa","profª","professor","professorin","profesor","professeur","professeurs","profesörü","professore","professora","professoressa","profesora","professori","profesorica","hoogleraar","emeritus","emerito","academician","honorable","honourable","senator","the honorable","the honourable","καθηγήτρια","καθηγητης","høgskolelektor","dr.","drs.","hjh.","hj.","lic.","ing.","ir.","med.","qco.","dra.","yrd.","doç.","dña.","académico","acad","доц","доцент","проф","профессор","профессорa","профессора","дмн","академик","ag prof"],"ai_section_keywords":["f i g u r e","f ig","f ig.","f igure","fig","fig.","figure no","figure no.","figure","illustration","figura","ilustración","ilustração","abbildung","abb","abb.","diagramm","diagr.","diagramme","illustrazione","figur","kuvio","kuviosta","kuva","kaavio","kuvassa","kuvan","kuvasta","kuvaa","kuvalle","gambar","ilustrasi","bagan","figür","şekil","illüstrasyon","resim","figurii","graficul","diagrama","ilustrația","ilustrației","ilustrarea","illustració","ilustrácia","ilustrácii","podľa ilustrácie","ilustráciu","ilustráciou","ilustrácii","obrázok","obrázku","obrázkom","obrázek","obr.","obr","obrázku","obrázkem","ilustrace","ilustraci","ilustrací","rysunek","rys.","rys","rysunku","rysunkowi","rysunkiem","rycina","z ryciny","do ryciny","rycinie","rycinę","ryciną","rycino","ilustracja","figuur","illustratie","slika","slike","sl.","slici","sliku","slikom","ilustracija","ilustraciji","ispod ilustracije","mynd","tekening","joonis","joonisel","joonisele","joonise","illustratsioon","illustratsioonil","illustratsiooniks","illustratsiooni","illustratsioonil","foto","attēls","attēlā","attēlu","ilustrācija","ilustrācijai","paveikslas","pav.","paveiksle","paveikslui","paveikslo","paveikslą","paveikslu","slika","sliki","sliko","ilustracijo","abra","ábrán","ábra","ábrához","ábrára","ábrával","hình","σχήμα","διάγραμμα","σχήματος","图","図","그림","рисунок","рисунка","рисунку","рисунком","рисунке","рис.","иллюстрация","на иллюстрации","у иллюстрации","по иллюстрации","иллюстрацию","иллюстрацией","малюнок","малюнак","малюнка","малюнком","малюнкам","малюнку","ілюстрація","ілюстрацыя","у ілюстрації","по ілюстрації","в ілюстрації","у ілюстрацыі","па ілюстрацыі","ілюстрацію","ілюстрацыю","ілюстрацією","ілюстрацыяй","фигура","илюстрация","слика","слици","испод слике"],"table_keywords":["t a b l e","t a b le","t a bl e","t ab l e","t ab","t ab.","t able","ta b l e","ta ble","tab le","tab","tab.","tabl e","table","table no","table no.","tbl","tbl.","tabla","tabela","tabelle","tableau","tabella","tabell","taulukko","taulukosta","taulukossa","taulukkoa","taulukon","taulukko","taulukkoon","tabel","tablo","tabelul","tabelului","taula","tabuľka","podľa tabuľky","tabuľku","tabuľke","tabuľkou","tabulka","podle tabulky","tab. č.","tabulku","tabulce","tabulkou","tabeli","tabelą","tafel","tablica","ispod tablice","tablicu","tablici","tablicom","tabele","borð","tafla","töflu","tabelile","tabelis","tabula","tabulā","tabulu","lentelė","lentelę","lentele","lentelėje","tabelo","táblázat","táblázatban","táblázathoz","táblázatra","táblázattal","bảng","πίνακας","πίνακα","表","표","таблица","таблицы","таблице","таблицой","таблиця","у таблиці","таблицею","табліца","у табліцы","па табліцы","табліцай","табела","табели","испод табеле","табл."],"chart_keywords":["chart","graph","cuad.","cuadro","gráfica","quad.","quadro","gráfico","graphique","grafico","grafik","görsel","gràfic","graf č","graf č.","graf","grafem","wykres","wykresem","wykresu","wykresie","grafiek","grafikon","grafikona","grafík","línurit","grafikas","grafiką","grafiko","grafiks","pēc grafika","no grafika","grafiku","grafikā","graafik","graafiku","graafikule","graafikust","graafikus","grafikonu","grafikonom","grafu","đồ thị","biểu đồ","γράφημα","график","графика","графику","графиком","в графике","графік","графіка","графіком","графікам","графіку","графіцы","графикон","графикону","графиконом","графикона"],"appendix_keywords":["appendix","annex","apendice","anhang","annexe","appendice","bilaga","liite","liitteessä","bilag","lampiran","ek","anexa","apèndix","príloha","załącznik","bijlage","dodatek","viðauka","dodatku","παράρτημα","附录","附錄","付録","부록","приложение","приложении","додаток","додатак","додатку","дадатак","дадатку","прыкладанне","прыкладанні"],"appendices_dict":["appendices","annexes","apendices","anexos","anhänge","appendici","bilagor","liitteet","ekler","annexos","prílohy","załączniki","bijlagen","παραρτήματα","приложения","додатки","дадаткі","прыкладанні"],"introduction_dict":["introduction","introductions","introducción","introdução","einleitung","introduzione","inledningen","esittely","indledningen","pendahuluan","giriş","introducerea","introducció","úvod","wstęp","inleiding","inngangur","sissejuhatus","ievads","įvadas","bevezetés","giới thiệu","εισαγωγή","简介","簡介","序章","소개","введение","вступ","увядзенне","въведение"],"keywords_dict":["index terms","key words","key-words","keywords component","keywords-component","proof of","proof of lemma","proof of proposition","proof of theorem","send offprint requests to"],"nk_thesis_words":["інститут","університет","университет","ступеня","степени","соискания","руководитель","оппоненты","опоненти","керівник","кандидата","институт","здобуття","доктор","диццертации","дисертаціі","universidade","universidad","under","trabalho","trabajo","titulo","titulo","thesis","these","these","tesis","tese","supervision","submitted","submetida","standard","specialite","school","requisitos","requisito","requirements","requirement","required","regente","redacao","rapporteurs","rapporteur","publico","prueba","progama","professor","profesores","profesor","profesional","prof","presidente","presentee","presentado","presentada","phdr","partial","parte","parte","parcial","orientadora","orientador","optar","obtenir","obtencion","obtencao","obtencao","obtencao","nombre","necessarios","miembros","mestrado","memoria","masters","master's","magister","jury","instituto","institute","guidance","grau","graduate","graduacao","grado","grade","fulfillment","fakultat","faculty","facultad","faculdade","exigencias","examiners","examiner","examinatrice","examinateurs","examinateur","examen","especializacion","escuela","erlangung","doutor","doktorgrades","doctorat","doctorat","doctorale","doctor","docteur","dissertation","dissertacao","directrice","directores","directeur","diplome","diploma","departmento","departmental","department","degree","defendida","corresponde","conforming","conclusao","concentracao","committee","codirectrice","codirecteur","certify","certified","centro","candidato","bacherlors","bachelor's","bacharel","area","aprovada","aprobada","apresentada","apresentacao","alumna","advisors","advisor","accept","TITULACION","Sra","Profesora","Profesor","PRESENTA","OPCION","OBTENER","MODALIDAD","Licenciado","Julgadora","Ingeniero","INTERNO","INFORME","Guia","Evaluadores","Evaluadora","Evaluador","ESCRITO","Dra","Doutorado","Doutor","Directores","Comissao","CURRICULAR","CORRESPONDIENTE","CONSEJEROS","COMISION","COLABORADOR","APROBACION"],"chapter_words":["chapter","chapter no","chapter no.","capitulo","kapitel","chapitre","capitolo","luku","luvussa","luvulle","luvun","bab","capitolul","capitol","capítol","kapitola","kapitole","kapitolu","kapitolou","rozdział","rozdziale","rozdziału","hoofdstuk","poglavlje","poglavlju","poglavljem","kafli","kafla","nodaļa","nodaļā","nodaļu","skyrius","skyriuje","skyriui","skyrių","fejezet","fejezetben","fejezethez","κεφάλαιο","κεφαλιο","章","장","глава","главы","главе","главу","главой","глави","главі","главою","главо","поглавље","поглављу","поглављем","раздел","раздела","разделу","разделом","разделе","розділ","розділу","розділі","розділом","раздзел","раздзела","раздзеле","раздзелам","одељак","одељку","одељком"]} \ No newline at end of file diff --git a/pageindex/flash/data/glyph_name_table.json b/pageindex/flash/data/glyph_name_table.json new file mode 100644 index 000000000..fae9786b0 --- /dev/null +++ b/pageindex/flash/data/glyph_name_table.json @@ -0,0 +1 @@ +{"glyphs":{"A":65,"AE":198,"AEacute":508,"AEmacron":482,"AEsmall":63462,"Aacute":193,"Aacutesmall":63457,"Abreve":258,"Abreveacute":7854,"Abrevecyrillic":1232,"Abrevedotbelow":7862,"Abrevegrave":7856,"Abrevehookabove":7858,"Abrevetilde":7860,"Acaron":461,"Acircle":9398,"Acircumflex":194,"Acircumflexacute":7844,"Acircumflexdotbelow":7852,"Acircumflexgrave":7846,"Acircumflexhookabove":7848,"Acircumflexsmall":63458,"Acircumflextilde":7850,"Acute":63177,"Acutesmall":63412,"Acyrillic":1040,"Adblgrave":512,"Adieresis":196,"Adieresiscyrillic":1234,"Adieresismacron":478,"Adieresissmall":63460,"Adotbelow":7840,"Adotmacron":480,"Agrave":192,"Agravesmall":63456,"Ahookabove":7842,"Aiecyrillic":1236,"Ainvertedbreve":514,"Alpha":913,"Alphatonos":902,"Amacron":256,"Amonospace":65313,"Aogonek":260,"Aring":197,"Aringacute":506,"Aringbelow":7680,"Aringsmall":63461,"Asmall":63329,"Atilde":195,"Atildesmall":63459,"Aybarmenian":1329,"B":66,"Bcircle":9399,"Bdotaccent":7682,"Bdotbelow":7684,"Becyrillic":1041,"Benarmenian":1330,"Beta":914,"Bhook":385,"Blinebelow":7686,"Bmonospace":65314,"Brevesmall":63220,"Bsmall":63330,"Btopbar":386,"C":67,"Caarmenian":1342,"Cacute":262,"Caron":63178,"Caronsmall":63221,"Ccaron":268,"Ccedilla":199,"Ccedillaacute":7688,"Ccedillasmall":63463,"Ccircle":9400,"Ccircumflex":264,"Cdot":266,"Cdotaccent":266,"Cedillasmall":63416,"Chaarmenian":1353,"Cheabkhasiancyrillic":1212,"Checyrillic":1063,"Chedescenderabkhasiancyrillic":1214,"Chedescendercyrillic":1206,"Chedieresiscyrillic":1268,"Cheharmenian":1347,"Chekhakassiancyrillic":1227,"Cheverticalstrokecyrillic":1208,"Chi":935,"Chook":391,"Circumflexsmall":63222,"Cmonospace":65315,"Coarmenian":1361,"Csmall":63331,"D":68,"DZ":497,"DZcaron":452,"Daarmenian":1332,"Dafrican":393,"Dcaron":270,"Dcedilla":7696,"Dcircle":9401,"Dcircumflexbelow":7698,"Dcroat":272,"Ddotaccent":7690,"Ddotbelow":7692,"Decyrillic":1044,"Deicoptic":1006,"Delta":8710,"Deltagreek":916,"Dhook":394,"Dieresis":63179,"DieresisAcute":63180,"DieresisGrave":63181,"Dieresissmall":63400,"Digammagreek":988,"Djecyrillic":1026,"Dlinebelow":7694,"Dmonospace":65316,"Dotaccentsmall":63223,"Dslash":272,"Dsmall":63332,"Dtopbar":395,"Dz":498,"Dzcaron":453,"Dzeabkhasiancyrillic":1248,"Dzecyrillic":1029,"Dzhecyrillic":1039,"E":69,"Eacute":201,"Eacutesmall":63465,"Ebreve":276,"Ecaron":282,"Ecedillabreve":7708,"Echarmenian":1333,"Ecircle":9402,"Ecircumflex":202,"Ecircumflexacute":7870,"Ecircumflexbelow":7704,"Ecircumflexdotbelow":7878,"Ecircumflexgrave":7872,"Ecircumflexhookabove":7874,"Ecircumflexsmall":63466,"Ecircumflextilde":7876,"Ecyrillic":1028,"Edblgrave":516,"Edieresis":203,"Edieresissmall":63467,"Edot":278,"Edotaccent":278,"Edotbelow":7864,"Efcyrillic":1060,"Egrave":200,"Egravesmall":63464,"Eharmenian":1335,"Ehookabove":7866,"Eightroman":8551,"Einvertedbreve":518,"Eiotifiedcyrillic":1124,"Elcyrillic":1051,"Elevenroman":8554,"Emacron":274,"Emacronacute":7702,"Emacrongrave":7700,"Emcyrillic":1052,"Emonospace":65317,"Encyrillic":1053,"Endescendercyrillic":1186,"Eng":330,"Enghecyrillic":1188,"Enhookcyrillic":1223,"Eogonek":280,"Eopen":400,"Epsilon":917,"Epsilontonos":904,"Ercyrillic":1056,"Ereversed":398,"Ereversedcyrillic":1069,"Escyrillic":1057,"Esdescendercyrillic":1194,"Esh":425,"Esmall":63333,"Eta":919,"Etarmenian":1336,"Etatonos":905,"Eth":208,"Ethsmall":63472,"Etilde":7868,"Etildebelow":7706,"Euro":8364,"Ezh":439,"Ezhcaron":494,"Ezhreversed":440,"F":70,"Fcircle":9403,"Fdotaccent":7710,"Feharmenian":1366,"Feicoptic":996,"Fhook":401,"Fitacyrillic":1138,"Fiveroman":8548,"Fmonospace":65318,"Fourroman":8547,"Fsmall":63334,"G":71,"GBsquare":13191,"Gacute":500,"Gamma":915,"Gammaafrican":404,"Gangiacoptic":1002,"Gbreve":286,"Gcaron":486,"Gcedilla":290,"Gcircle":9404,"Gcircumflex":284,"Gcommaaccent":290,"Gdot":288,"Gdotaccent":288,"Gecyrillic":1043,"Ghadarmenian":1346,"Ghemiddlehookcyrillic":1172,"Ghestrokecyrillic":1170,"Gheupturncyrillic":1168,"Ghook":403,"Gimarmenian":1331,"Gjecyrillic":1027,"Gmacron":7712,"Gmonospace":65319,"Grave":63182,"Gravesmall":63328,"Gsmall":63335,"Gsmallhook":667,"Gstroke":484,"H":72,"H18533":9679,"H18543":9642,"H18551":9643,"H22073":9633,"HPsquare":13259,"Haabkhasiancyrillic":1192,"Hadescendercyrillic":1202,"Hardsigncyrillic":1066,"Hbar":294,"Hbrevebelow":7722,"Hcedilla":7720,"Hcircle":9405,"Hcircumflex":292,"Hdieresis":7718,"Hdotaccent":7714,"Hdotbelow":7716,"Hmonospace":65320,"Hoarmenian":1344,"Horicoptic":1000,"Hsmall":63336,"Hungarumlaut":63183,"Hungarumlautsmall":63224,"Hzsquare":13200,"I":73,"IAcyrillic":1071,"IJ":306,"IUcyrillic":1070,"Iacute":205,"Iacutesmall":63469,"Ibreve":300,"Icaron":463,"Icircle":9406,"Icircumflex":206,"Icircumflexsmall":63470,"Icyrillic":1030,"Idblgrave":520,"Idieresis":207,"Idieresisacute":7726,"Idieresiscyrillic":1252,"Idieresissmall":63471,"Idot":304,"Idotaccent":304,"Idotbelow":7882,"Iebrevecyrillic":1238,"Iecyrillic":1045,"Ifraktur":8465,"Igrave":204,"Igravesmall":63468,"Ihookabove":7880,"Iicyrillic":1048,"Iinvertedbreve":522,"Iishortcyrillic":1049,"Imacron":298,"Imacroncyrillic":1250,"Imonospace":65321,"Iniarmenian":1339,"Iocyrillic":1025,"Iogonek":302,"Iota":921,"Iotaafrican":406,"Iotadieresis":938,"Iotatonos":906,"Ismall":63337,"Istroke":407,"Itilde":296,"Itildebelow":7724,"Izhitsacyrillic":1140,"Izhitsadblgravecyrillic":1142,"J":74,"Jaarmenian":1345,"Jcircle":9407,"Jcircumflex":308,"Jecyrillic":1032,"Jheharmenian":1355,"Jmonospace":65322,"Jsmall":63338,"K":75,"KBsquare":13189,"KKsquare":13261,"Kabashkircyrillic":1184,"Kacute":7728,"Kacyrillic":1050,"Kadescendercyrillic":1178,"Kahookcyrillic":1219,"Kappa":922,"Kastrokecyrillic":1182,"Kaverticalstrokecyrillic":1180,"Kcaron":488,"Kcedilla":310,"Kcircle":9408,"Kcommaaccent":310,"Kdotbelow":7730,"Keharmenian":1364,"Kenarmenian":1343,"Khacyrillic":1061,"Kheicoptic":998,"Khook":408,"Kjecyrillic":1036,"Klinebelow":7732,"Kmonospace":65323,"Koppacyrillic":1152,"Koppagreek":990,"Ksicyrillic":1134,"Ksmall":63339,"L":76,"LJ":455,"LL":63167,"Lacute":313,"Lambda":923,"Lcaron":317,"Lcedilla":315,"Lcircle":9409,"Lcircumflexbelow":7740,"Lcommaaccent":315,"Ldot":319,"Ldotaccent":319,"Ldotbelow":7734,"Ldotbelowmacron":7736,"Liwnarmenian":1340,"Lj":456,"Ljecyrillic":1033,"Llinebelow":7738,"Lmonospace":65324,"Lslash":321,"Lslashsmall":63225,"Lsmall":63340,"M":77,"MBsquare":13190,"Macron":63184,"Macronsmall":63407,"Macute":7742,"Mcircle":9410,"Mdotaccent":7744,"Mdotbelow":7746,"Menarmenian":1348,"Mmonospace":65325,"Msmall":63341,"Mturned":412,"Mu":924,"N":78,"NJ":458,"Nacute":323,"Ncaron":327,"Ncedilla":325,"Ncircle":9411,"Ncircumflexbelow":7754,"Ncommaaccent":325,"Ndotaccent":7748,"Ndotbelow":7750,"Nhookleft":413,"Nineroman":8552,"Nj":459,"Njecyrillic":1034,"Nlinebelow":7752,"Nmonospace":65326,"Nowarmenian":1350,"Nsmall":63342,"Ntilde":209,"Ntildesmall":63473,"Nu":925,"O":79,"OE":338,"OEsmall":63226,"Oacute":211,"Oacutesmall":63475,"Obarredcyrillic":1256,"Obarreddieresiscyrillic":1258,"Obreve":334,"Ocaron":465,"Ocenteredtilde":415,"Ocircle":9412,"Ocircumflex":212,"Ocircumflexacute":7888,"Ocircumflexdotbelow":7896,"Ocircumflexgrave":7890,"Ocircumflexhookabove":7892,"Ocircumflexsmall":63476,"Ocircumflextilde":7894,"Ocyrillic":1054,"Odblacute":336,"Odblgrave":524,"Odieresis":214,"Odieresiscyrillic":1254,"Odieresissmall":63478,"Odotbelow":7884,"Ogoneksmall":63227,"Ograve":210,"Ogravesmall":63474,"Oharmenian":1365,"Ohm":8486,"Ohookabove":7886,"Ohorn":416,"Ohornacute":7898,"Ohorndotbelow":7906,"Ohorngrave":7900,"Ohornhookabove":7902,"Ohorntilde":7904,"Ohungarumlaut":336,"Oi":418,"Oinvertedbreve":526,"Omacron":332,"Omacronacute":7762,"Omacrongrave":7760,"Omega":8486,"Omegacyrillic":1120,"Omegagreek":937,"Omegaroundcyrillic":1146,"Omegatitlocyrillic":1148,"Omegatonos":911,"Omicron":927,"Omicrontonos":908,"Omonospace":65327,"Oneroman":8544,"Oogonek":490,"Oogonekmacron":492,"Oopen":390,"Oslash":216,"Oslashacute":510,"Oslashsmall":63480,"Osmall":63343,"Ostrokeacute":510,"Otcyrillic":1150,"Otilde":213,"Otildeacute":7756,"Otildedieresis":7758,"Otildesmall":63477,"P":80,"Pacute":7764,"Pcircle":9413,"Pdotaccent":7766,"Pecyrillic":1055,"Peharmenian":1354,"Pemiddlehookcyrillic":1190,"Phi":934,"Phook":420,"Pi":928,"Piwrarmenian":1363,"Pmonospace":65328,"Psi":936,"Psicyrillic":1136,"Psmall":63344,"Q":81,"Qcircle":9414,"Qmonospace":65329,"Qsmall":63345,"R":82,"Raarmenian":1356,"Racute":340,"Rcaron":344,"Rcedilla":342,"Rcircle":9415,"Rcommaaccent":342,"Rdblgrave":528,"Rdotaccent":7768,"Rdotbelow":7770,"Rdotbelowmacron":7772,"Reharmenian":1360,"Rfraktur":8476,"Rho":929,"Ringsmall":63228,"Rinvertedbreve":530,"Rlinebelow":7774,"Rmonospace":65330,"Rsmall":63346,"Rsmallinverted":641,"Rsmallinvertedsuperior":694,"S":83,"SF010000":9484,"SF020000":9492,"SF030000":9488,"SF040000":9496,"SF050000":9532,"SF060000":9516,"SF070000":9524,"SF080000":9500,"SF090000":9508,"SF100000":9472,"SF110000":9474,"SF190000":9569,"SF200000":9570,"SF210000":9558,"SF220000":9557,"SF230000":9571,"SF240000":9553,"SF250000":9559,"SF260000":9565,"SF270000":9564,"SF280000":9563,"SF360000":9566,"SF370000":9567,"SF380000":9562,"SF390000":9556,"SF400000":9577,"SF410000":9574,"SF420000":9568,"SF430000":9552,"SF440000":9580,"SF450000":9575,"SF460000":9576,"SF470000":9572,"SF480000":9573,"SF490000":9561,"SF500000":9560,"SF510000":9554,"SF520000":9555,"SF530000":9579,"SF540000":9578,"Sacute":346,"Sacutedotaccent":7780,"Sampigreek":992,"Scaron":352,"Scarondotaccent":7782,"Scaronsmall":63229,"Scedilla":350,"Schwa":399,"Schwacyrillic":1240,"Schwadieresiscyrillic":1242,"Scircle":9416,"Scircumflex":348,"Scommaaccent":536,"Sdotaccent":7776,"Sdotbelow":7778,"Sdotbelowdotaccent":7784,"Seharmenian":1357,"Sevenroman":8550,"Shaarmenian":1351,"Shacyrillic":1064,"Shchacyrillic":1065,"Sheicoptic":994,"Shhacyrillic":1210,"Shimacoptic":1004,"Sigma":931,"Sixroman":8549,"Smonospace":65331,"Softsigncyrillic":1068,"Ssmall":63347,"Stigmagreek":986,"T":84,"Tau":932,"Tbar":358,"Tcaron":356,"Tcedilla":354,"Tcircle":9417,"Tcircumflexbelow":7792,"Tcommaaccent":354,"Tdotaccent":7786,"Tdotbelow":7788,"Tecyrillic":1058,"Tedescendercyrillic":1196,"Tenroman":8553,"Tetsecyrillic":1204,"Theta":920,"Thook":428,"Thorn":222,"Thornsmall":63486,"Threeroman":8546,"Tildesmall":63230,"Tiwnarmenian":1359,"Tlinebelow":7790,"Tmonospace":65332,"Toarmenian":1337,"Tonefive":444,"Tonesix":388,"Tonetwo":423,"Tretroflexhook":430,"Tsecyrillic":1062,"Tshecyrillic":1035,"Tsmall":63348,"Twelveroman":8555,"Tworoman":8545,"U":85,"Uacute":218,"Uacutesmall":63482,"Ubreve":364,"Ucaron":467,"Ucircle":9418,"Ucircumflex":219,"Ucircumflexbelow":7798,"Ucircumflexsmall":63483,"Ucyrillic":1059,"Udblacute":368,"Udblgrave":532,"Udieresis":220,"Udieresisacute":471,"Udieresisbelow":7794,"Udieresiscaron":473,"Udieresiscyrillic":1264,"Udieresisgrave":475,"Udieresismacron":469,"Udieresissmall":63484,"Udotbelow":7908,"Ugrave":217,"Ugravesmall":63481,"Uhookabove":7910,"Uhorn":431,"Uhornacute":7912,"Uhorndotbelow":7920,"Uhorngrave":7914,"Uhornhookabove":7916,"Uhorntilde":7918,"Uhungarumlaut":368,"Uhungarumlautcyrillic":1266,"Uinvertedbreve":534,"Ukcyrillic":1144,"Umacron":362,"Umacroncyrillic":1262,"Umacrondieresis":7802,"Umonospace":65333,"Uogonek":370,"Upsilon":933,"Upsilon1":978,"Upsilonacutehooksymbolgreek":979,"Upsilonafrican":433,"Upsilondieresis":939,"Upsilondieresishooksymbolgreek":980,"Upsilonhooksymbol":978,"Upsilontonos":910,"Uring":366,"Ushortcyrillic":1038,"Usmall":63349,"Ustraightcyrillic":1198,"Ustraightstrokecyrillic":1200,"Utilde":360,"Utildeacute":7800,"Utildebelow":7796,"V":86,"Vcircle":9419,"Vdotbelow":7806,"Vecyrillic":1042,"Vewarmenian":1358,"Vhook":434,"Vmonospace":65334,"Voarmenian":1352,"Vsmall":63350,"Vtilde":7804,"W":87,"Wacute":7810,"Wcircle":9420,"Wcircumflex":372,"Wdieresis":7812,"Wdotaccent":7814,"Wdotbelow":7816,"Wgrave":7808,"Wmonospace":65335,"Wsmall":63351,"X":88,"Xcircle":9421,"Xdieresis":7820,"Xdotaccent":7818,"Xeharmenian":1341,"Xi":926,"Xmonospace":65336,"Xsmall":63352,"Y":89,"Yacute":221,"Yacutesmall":63485,"Yatcyrillic":1122,"Ycircle":9422,"Ycircumflex":374,"Ydieresis":376,"Ydieresissmall":63487,"Ydotaccent":7822,"Ydotbelow":7924,"Yericyrillic":1067,"Yerudieresiscyrillic":1272,"Ygrave":7922,"Yhook":435,"Yhookabove":7926,"Yiarmenian":1349,"Yicyrillic":1031,"Yiwnarmenian":1362,"Ymonospace":65337,"Ysmall":63353,"Ytilde":7928,"Yusbigcyrillic":1130,"Yusbigiotifiedcyrillic":1132,"Yuslittlecyrillic":1126,"Yuslittleiotifiedcyrillic":1128,"Z":90,"Zaarmenian":1334,"Zacute":377,"Zcaron":381,"Zcaronsmall":63231,"Zcircle":9423,"Zcircumflex":7824,"Zdot":379,"Zdotaccent":379,"Zdotbelow":7826,"Zecyrillic":1047,"Zedescendercyrillic":1176,"Zedieresiscyrillic":1246,"Zeta":918,"Zhearmenian":1338,"Zhebrevecyrillic":1217,"Zhecyrillic":1046,"Zhedescendercyrillic":1174,"Zhedieresiscyrillic":1244,"Zlinebelow":7828,"Zmonospace":65338,"Zsmall":63354,"Zstroke":437,"a":97,"aabengali":2438,"aacute":225,"aadeva":2310,"aagujarati":2694,"aagurmukhi":2566,"aamatragurmukhi":2622,"aarusquare":13059,"aavowelsignbengali":2494,"aavowelsigndeva":2366,"aavowelsigngujarati":2750,"abbreviationmarkarmenian":1375,"abbreviationsigndeva":2416,"abengali":2437,"abopomofo":12570,"abreve":259,"abreveacute":7855,"abrevecyrillic":1233,"abrevedotbelow":7863,"abrevegrave":7857,"abrevehookabove":7859,"abrevetilde":7861,"acaron":462,"acircle":9424,"acircumflex":226,"acircumflexacute":7845,"acircumflexdotbelow":7853,"acircumflexgrave":7847,"acircumflexhookabove":7849,"acircumflextilde":7851,"acute":180,"acutebelowcmb":791,"acutecmb":769,"acutecomb":769,"acutedeva":2388,"acutelowmod":719,"acutetonecmb":833,"acyrillic":1072,"adblgrave":513,"addakgurmukhi":2673,"adeva":2309,"adieresis":228,"adieresiscyrillic":1235,"adieresismacron":479,"adotbelow":7841,"adotmacron":481,"ae":230,"aeacute":509,"aekorean":12624,"aemacron":483,"afii00208":8213,"afii08941":8356,"afii10017":1040,"afii10018":1041,"afii10019":1042,"afii10020":1043,"afii10021":1044,"afii10022":1045,"afii10023":1025,"afii10024":1046,"afii10025":1047,"afii10026":1048,"afii10027":1049,"afii10028":1050,"afii10029":1051,"afii10030":1052,"afii10031":1053,"afii10032":1054,"afii10033":1055,"afii10034":1056,"afii10035":1057,"afii10036":1058,"afii10037":1059,"afii10038":1060,"afii10039":1061,"afii10040":1062,"afii10041":1063,"afii10042":1064,"afii10043":1065,"afii10044":1066,"afii10045":1067,"afii10046":1068,"afii10047":1069,"afii10048":1070,"afii10049":1071,"afii10050":1168,"afii10051":1026,"afii10052":1027,"afii10053":1028,"afii10054":1029,"afii10055":1030,"afii10056":1031,"afii10057":1032,"afii10058":1033,"afii10059":1034,"afii10060":1035,"afii10061":1036,"afii10062":1038,"afii10063":63172,"afii10064":63173,"afii10065":1072,"afii10066":1073,"afii10067":1074,"afii10068":1075,"afii10069":1076,"afii10070":1077,"afii10071":1105,"afii10072":1078,"afii10073":1079,"afii10074":1080,"afii10075":1081,"afii10076":1082,"afii10077":1083,"afii10078":1084,"afii10079":1085,"afii10080":1086,"afii10081":1087,"afii10082":1088,"afii10083":1089,"afii10084":1090,"afii10085":1091,"afii10086":1092,"afii10087":1093,"afii10088":1094,"afii10089":1095,"afii10090":1096,"afii10091":1097,"afii10092":1098,"afii10093":1099,"afii10094":1100,"afii10095":1101,"afii10096":1102,"afii10097":1103,"afii10098":1169,"afii10099":1106,"afii10100":1107,"afii10101":1108,"afii10102":1109,"afii10103":1110,"afii10104":1111,"afii10105":1112,"afii10106":1113,"afii10107":1114,"afii10108":1115,"afii10109":1116,"afii10110":1118,"afii10145":1039,"afii10146":1122,"afii10147":1138,"afii10148":1140,"afii10192":63174,"afii10193":1119,"afii10194":1123,"afii10195":1139,"afii10196":1141,"afii10831":63175,"afii10832":63176,"afii10846":1241,"afii299":8206,"afii300":8207,"afii301":8205,"afii57381":1642,"afii57388":1548,"afii57392":1632,"afii57393":1633,"afii57394":1634,"afii57395":1635,"afii57396":1636,"afii57397":1637,"afii57398":1638,"afii57399":1639,"afii57400":1640,"afii57401":1641,"afii57403":1563,"afii57407":1567,"afii57409":1569,"afii57410":1570,"afii57411":1571,"afii57412":1572,"afii57413":1573,"afii57414":1574,"afii57415":1575,"afii57416":1576,"afii57417":1577,"afii57418":1578,"afii57419":1579,"afii57420":1580,"afii57421":1581,"afii57422":1582,"afii57423":1583,"afii57424":1584,"afii57425":1585,"afii57426":1586,"afii57427":1587,"afii57428":1588,"afii57429":1589,"afii57430":1590,"afii57431":1591,"afii57432":1592,"afii57433":1593,"afii57434":1594,"afii57440":1600,"afii57441":1601,"afii57442":1602,"afii57443":1603,"afii57444":1604,"afii57445":1605,"afii57446":1606,"afii57448":1608,"afii57449":1609,"afii57450":1610,"afii57451":1611,"afii57452":1612,"afii57453":1613,"afii57454":1614,"afii57455":1615,"afii57456":1616,"afii57457":1617,"afii57458":1618,"afii57470":1607,"afii57505":1700,"afii57506":1662,"afii57507":1670,"afii57508":1688,"afii57509":1711,"afii57511":1657,"afii57512":1672,"afii57513":1681,"afii57514":1722,"afii57519":1746,"afii57534":1749,"afii57636":8362,"afii57645":1470,"afii57658":1475,"afii57664":1488,"afii57665":1489,"afii57666":1490,"afii57667":1491,"afii57668":1492,"afii57669":1493,"afii57670":1494,"afii57671":1495,"afii57672":1496,"afii57673":1497,"afii57674":1498,"afii57675":1499,"afii57676":1500,"afii57677":1501,"afii57678":1502,"afii57679":1503,"afii57680":1504,"afii57681":1505,"afii57682":1506,"afii57683":1507,"afii57684":1508,"afii57685":1509,"afii57686":1510,"afii57687":1511,"afii57688":1512,"afii57689":1513,"afii57690":1514,"afii57694":64298,"afii57695":64299,"afii57700":64331,"afii57705":64287,"afii57716":1520,"afii57717":1521,"afii57718":1522,"afii57723":64309,"afii57793":1460,"afii57794":1461,"afii57795":1462,"afii57796":1467,"afii57797":1464,"afii57798":1463,"afii57799":1456,"afii57800":1458,"afii57801":1457,"afii57802":1459,"afii57803":1474,"afii57804":1473,"afii57806":1465,"afii57807":1468,"afii57839":1469,"afii57841":1471,"afii57842":1472,"afii57929":700,"afii61248":8453,"afii61289":8467,"afii61352":8470,"afii61573":8236,"afii61574":8237,"afii61575":8238,"afii61664":8204,"afii63167":1645,"afii64937":701,"agrave":224,"agujarati":2693,"agurmukhi":2565,"ahiragana":12354,"ahookabove":7843,"aibengali":2448,"aibopomofo":12574,"aideva":2320,"aiecyrillic":1237,"aigujarati":2704,"aigurmukhi":2576,"aimatragurmukhi":2632,"ainarabic":1593,"ainfinalarabic":65226,"aininitialarabic":65227,"ainmedialarabic":65228,"ainvertedbreve":515,"aivowelsignbengali":2504,"aivowelsigndeva":2376,"aivowelsigngujarati":2760,"akatakana":12450,"akatakanahalfwidth":65393,"akorean":12623,"alef":1488,"alefarabic":1575,"alefdageshhebrew":64304,"aleffinalarabic":65166,"alefhamzaabovearabic":1571,"alefhamzaabovefinalarabic":65156,"alefhamzabelowarabic":1573,"alefhamzabelowfinalarabic":65160,"alefhebrew":1488,"aleflamedhebrew":64335,"alefmaddaabovearabic":1570,"alefmaddaabovefinalarabic":65154,"alefmaksuraarabic":1609,"alefmaksurafinalarabic":65264,"alefmaksurainitialarabic":65267,"alefmaksuramedialarabic":65268,"alefpatahhebrew":64302,"alefqamatshebrew":64303,"aleph":8501,"allequal":8780,"alpha":945,"alphatonos":940,"amacron":257,"amonospace":65345,"ampersand":38,"ampersandmonospace":65286,"ampersandsmall":63270,"amsquare":13250,"anbopomofo":12578,"angbopomofo":12580,"angbracketleft":12296,"angbracketright":12297,"angkhankhuthai":3674,"angle":8736,"anglebracketleft":12296,"anglebracketleftvertical":65087,"anglebracketright":12297,"anglebracketrightvertical":65088,"angleleft":9001,"angleright":9002,"angstrom":8491,"anoteleia":903,"anudattadeva":2386,"anusvarabengali":2434,"anusvaradeva":2306,"anusvaragujarati":2690,"aogonek":261,"apaatosquare":13056,"aparen":9372,"apostrophearmenian":1370,"apostrophemod":700,"apple":63743,"approaches":8784,"approxequal":8776,"approxequalorimage":8786,"approximatelyequal":8773,"araeaekorean":12686,"araeakorean":12685,"arc":8978,"arighthalfring":7834,"aring":229,"aringacute":507,"aringbelow":7681,"arrowboth":8596,"arrowdashdown":8675,"arrowdashleft":8672,"arrowdashright":8674,"arrowdashup":8673,"arrowdblboth":8660,"arrowdbldown":8659,"arrowdblleft":8656,"arrowdblright":8658,"arrowdblup":8657,"arrowdown":8595,"arrowdownleft":8601,"arrowdownright":8600,"arrowdownwhite":8681,"arrowheaddownmod":709,"arrowheadleftmod":706,"arrowheadrightmod":707,"arrowheadupmod":708,"arrowhorizex":63719,"arrowleft":8592,"arrowleftdbl":8656,"arrowleftdblstroke":8653,"arrowleftoverright":8646,"arrowleftwhite":8678,"arrowright":8594,"arrowrightdblstroke":8655,"arrowrightheavy":10142,"arrowrightoverleft":8644,"arrowrightwhite":8680,"arrowtableft":8676,"arrowtabright":8677,"arrowup":8593,"arrowupdn":8597,"arrowupdnbse":8616,"arrowupdownbase":8616,"arrowupleft":8598,"arrowupleftofdown":8645,"arrowupright":8599,"arrowupwhite":8679,"arrowvertex":63718,"asciicircum":94,"asciicircummonospace":65342,"asciitilde":126,"asciitildemonospace":65374,"ascript":593,"ascriptturned":594,"asmallhiragana":12353,"asmallkatakana":12449,"asmallkatakanahalfwidth":65383,"asterisk":42,"asteriskaltonearabic":1645,"asteriskarabic":1645,"asteriskmath":8727,"asteriskmonospace":65290,"asterisksmall":65121,"asterism":8258,"asuperior":63209,"asymptoticallyequal":8771,"at":64,"atilde":227,"atmonospace":65312,"atsmall":65131,"aturned":592,"aubengali":2452,"aubopomofo":12576,"audeva":2324,"augujarati":2708,"augurmukhi":2580,"aulengthmarkbengali":2519,"aumatragurmukhi":2636,"auvowelsignbengali":2508,"auvowelsigndeva":2380,"auvowelsigngujarati":2764,"avagrahadeva":2365,"aybarmenian":1377,"ayin":1506,"ayinaltonehebrew":64288,"ayinhebrew":1506,"b":98,"babengali":2476,"backslash":92,"backslashmonospace":65340,"badeva":2348,"bagujarati":2732,"bagurmukhi":2604,"bahiragana":12400,"bahtthai":3647,"bakatakana":12496,"bar":124,"barmonospace":65372,"bbopomofo":12549,"bcircle":9425,"bdotaccent":7683,"bdotbelow":7685,"beamedsixteenthnotes":9836,"because":8757,"becyrillic":1073,"beharabic":1576,"behfinalarabic":65168,"behinitialarabic":65169,"behiragana":12409,"behmedialarabic":65170,"behmeeminitialarabic":64671,"behmeemisolatedarabic":64520,"behnoonfinalarabic":64621,"bekatakana":12505,"benarmenian":1378,"bet":1489,"beta":946,"betasymbolgreek":976,"betdagesh":64305,"betdageshhebrew":64305,"bethebrew":1489,"betrafehebrew":64332,"bhabengali":2477,"bhadeva":2349,"bhagujarati":2733,"bhagurmukhi":2605,"bhook":595,"bihiragana":12403,"bikatakana":12499,"bilabialclick":664,"bindigurmukhi":2562,"birusquare":13105,"blackcircle":9679,"blackdiamond":9670,"blackdownpointingtriangle":9660,"blackleftpointingpointer":9668,"blackleftpointingtriangle":9664,"blacklenticularbracketleft":12304,"blacklenticularbracketleftvertical":65083,"blacklenticularbracketright":12305,"blacklenticularbracketrightvertical":65084,"blacklowerlefttriangle":9699,"blacklowerrighttriangle":9698,"blackrectangle":9644,"blackrightpointingpointer":9658,"blackrightpointingtriangle":9654,"blacksmallsquare":9642,"blacksmilingface":9787,"blacksquare":9632,"blackstar":9733,"blackupperlefttriangle":9700,"blackupperrighttriangle":9701,"blackuppointingsmalltriangle":9652,"blackuppointingtriangle":9650,"blank":9251,"blinebelow":7687,"block":9608,"bmonospace":65346,"bobaimaithai":3610,"bohiragana":12412,"bokatakana":12508,"bparen":9373,"bqsquare":13251,"braceex":63732,"braceleft":123,"braceleftbt":63731,"braceleftmid":63730,"braceleftmonospace":65371,"braceleftsmall":65115,"bracelefttp":63729,"braceleftvertical":65079,"braceright":125,"bracerightbt":63742,"bracerightmid":63741,"bracerightmonospace":65373,"bracerightsmall":65116,"bracerighttp":63740,"bracerightvertical":65080,"bracketleft":91,"bracketleftbt":63728,"bracketleftex":63727,"bracketleftmonospace":65339,"bracketlefttp":63726,"bracketright":93,"bracketrightbt":63739,"bracketrightex":63738,"bracketrightmonospace":65341,"bracketrighttp":63737,"breve":728,"brevebelowcmb":814,"brevecmb":774,"breveinvertedbelowcmb":815,"breveinvertedcmb":785,"breveinverteddoublecmb":865,"bridgebelowcmb":810,"bridgeinvertedbelowcmb":826,"brokenbar":166,"bstroke":384,"bsuperior":63210,"btopbar":387,"buhiragana":12406,"bukatakana":12502,"bullet":8226,"bulletinverse":9688,"bulletoperator":8729,"bullseye":9678,"c":99,"caarmenian":1390,"cabengali":2458,"cacute":263,"cadeva":2330,"cagujarati":2714,"cagurmukhi":2586,"calsquare":13192,"candrabindubengali":2433,"candrabinducmb":784,"candrabindudeva":2305,"candrabindugujarati":2689,"capslock":8682,"careof":8453,"caron":711,"caronbelowcmb":812,"caroncmb":780,"carriagereturn":8629,"cbopomofo":12568,"ccaron":269,"ccedilla":231,"ccedillaacute":7689,"ccircle":9426,"ccircumflex":265,"ccurl":597,"cdot":267,"cdotaccent":267,"cdsquare":13253,"cedilla":184,"cedillacmb":807,"cent":162,"centigrade":8451,"centinferior":63199,"centmonospace":65504,"centoldstyle":63394,"centsuperior":63200,"chaarmenian":1401,"chabengali":2459,"chadeva":2331,"chagujarati":2715,"chagurmukhi":2587,"chbopomofo":12564,"cheabkhasiancyrillic":1213,"checkmark":10003,"checyrillic":1095,"chedescenderabkhasiancyrillic":1215,"chedescendercyrillic":1207,"chedieresiscyrillic":1269,"cheharmenian":1395,"chekhakassiancyrillic":1228,"cheverticalstrokecyrillic":1209,"chi":967,"chieuchacirclekorean":12919,"chieuchaparenkorean":12823,"chieuchcirclekorean":12905,"chieuchkorean":12618,"chieuchparenkorean":12809,"chochangthai":3594,"chochanthai":3592,"chochingthai":3593,"chochoethai":3596,"chook":392,"cieucacirclekorean":12918,"cieucaparenkorean":12822,"cieuccirclekorean":12904,"cieuckorean":12616,"cieucparenkorean":12808,"cieucuparenkorean":12828,"circle":9675,"circlecopyrt":169,"circlemultiply":8855,"circleot":8857,"circleplus":8853,"circlepostalmark":12342,"circlewithlefthalfblack":9680,"circlewithrighthalfblack":9681,"circumflex":710,"circumflexbelowcmb":813,"circumflexcmb":770,"clear":8999,"clickalveolar":450,"clickdental":448,"clicklateral":449,"clickretroflex":451,"club":9827,"clubsuitblack":9827,"clubsuitwhite":9831,"cmcubedsquare":13220,"cmonospace":65347,"cmsquaredsquare":13216,"coarmenian":1409,"colon":58,"colonmonetary":8353,"colonmonospace":65306,"colonsign":8353,"colonsmall":65109,"colontriangularhalfmod":721,"colontriangularmod":720,"comma":44,"commaabovecmb":787,"commaaboverightcmb":789,"commaaccent":63171,"commaarabic":1548,"commaarmenian":1373,"commainferior":63201,"commamonospace":65292,"commareversedabovecmb":788,"commareversedmod":701,"commasmall":65104,"commasuperior":63202,"commaturnedabovecmb":786,"commaturnedmod":699,"compass":9788,"congruent":8773,"contourintegral":8750,"control":8963,"controlACK":6,"controlBEL":7,"controlBS":8,"controlCAN":24,"controlCR":13,"controlDC1":17,"controlDC2":18,"controlDC3":19,"controlDC4":20,"controlDEL":127,"controlDLE":16,"controlEM":25,"controlENQ":5,"controlEOT":4,"controlESC":27,"controlETB":23,"controlETX":3,"controlFF":12,"controlFS":28,"controlGS":29,"controlHT":9,"controlLF":10,"controlNAK":21,"controlNULL":0,"controlRS":30,"controlSI":15,"controlSO":14,"controlSOT":2,"controlSTX":1,"controlSUB":26,"controlSYN":22,"controlUS":31,"controlVT":11,"copyright":169,"copyrightsans":63721,"copyrightserif":63193,"cornerbracketleft":12300,"cornerbracketlefthalfwidth":65378,"cornerbracketleftvertical":65089,"cornerbracketright":12301,"cornerbracketrighthalfwidth":65379,"cornerbracketrightvertical":65090,"corporationsquare":13183,"cosquare":13255,"coverkgsquare":13254,"cparen":9374,"cruzeiro":8354,"cstretched":663,"curlyand":8911,"curlyor":8910,"currency":164,"cyrBreve":63185,"cyrFlex":63186,"cyrbreve":63188,"cyrflex":63189,"d":100,"daarmenian":1380,"dabengali":2470,"dadarabic":1590,"dadeva":2342,"dadfinalarabic":65214,"dadinitialarabic":65215,"dadmedialarabic":65216,"dagesh":1468,"dageshhebrew":1468,"dagger":8224,"daggerdbl":8225,"dagujarati":2726,"dagurmukhi":2598,"dahiragana":12384,"dakatakana":12480,"dalarabic":1583,"dalet":1491,"daletdagesh":64307,"daletdageshhebrew":64307,"dalethebrew":1491,"dalfinalarabic":65194,"dammaarabic":1615,"dammalowarabic":1615,"dammatanaltonearabic":1612,"dammatanarabic":1612,"danda":2404,"dargahebrew":1447,"dargalefthebrew":1447,"dasiapneumatacyrilliccmb":1157,"dblGrave":63187,"dblanglebracketleft":12298,"dblanglebracketleftvertical":65085,"dblanglebracketright":12299,"dblanglebracketrightvertical":65086,"dblarchinvertedbelowcmb":811,"dblarrowleft":8660,"dblarrowright":8658,"dbldanda":2405,"dblgrave":63190,"dblgravecmb":783,"dblintegral":8748,"dbllowline":8215,"dbllowlinecmb":819,"dbloverlinecmb":831,"dblprimemod":698,"dblverticalbar":8214,"dblverticallineabovecmb":782,"dbopomofo":12553,"dbsquare":13256,"dcaron":271,"dcedilla":7697,"dcircle":9427,"dcircumflexbelow":7699,"dcroat":273,"ddabengali":2465,"ddadeva":2337,"ddagujarati":2721,"ddagurmukhi":2593,"ddalarabic":1672,"ddalfinalarabic":64393,"dddhadeva":2396,"ddhabengali":2466,"ddhadeva":2338,"ddhagujarati":2722,"ddhagurmukhi":2594,"ddotaccent":7691,"ddotbelow":7693,"decimalseparatorarabic":1643,"decimalseparatorpersian":1643,"decyrillic":1076,"degree":176,"dehihebrew":1453,"dehiragana":12391,"deicoptic":1007,"dekatakana":12487,"deleteleft":9003,"deleteright":8998,"delta":948,"deltaturned":397,"denominatorminusonenumeratorbengali":2552,"dezh":676,"dhabengali":2471,"dhadeva":2343,"dhagujarati":2727,"dhagurmukhi":2599,"dhook":599,"dialytikatonos":901,"dialytikatonoscmb":836,"diamond":9830,"diamondsuitwhite":9826,"dieresis":168,"dieresisacute":63191,"dieresisbelowcmb":804,"dieresiscmb":776,"dieresisgrave":63192,"dieresistonos":901,"dihiragana":12386,"dikatakana":12482,"dittomark":12291,"divide":247,"divides":8739,"divisionslash":8725,"djecyrillic":1106,"dkshade":9619,"dlinebelow":7695,"dlsquare":13207,"dmacron":273,"dmonospace":65348,"dnblock":9604,"dochadathai":3598,"dodekthai":3604,"dohiragana":12393,"dokatakana":12489,"dollar":36,"dollarinferior":63203,"dollarmonospace":65284,"dollaroldstyle":63268,"dollarsmall":65129,"dollarsuperior":63204,"dong":8363,"dorusquare":13094,"dotaccent":729,"dotaccentcmb":775,"dotbelowcmb":803,"dotbelowcomb":803,"dotkatakana":12539,"dotlessi":305,"dotlessj":63166,"dotlessjstrokehook":644,"dotmath":8901,"dottedcircle":9676,"doubleyodpatah":64287,"doubleyodpatahhebrew":64287,"downtackbelowcmb":798,"downtackmod":725,"dparen":9375,"dsuperior":63211,"dtail":598,"dtopbar":396,"duhiragana":12389,"dukatakana":12485,"dz":499,"dzaltone":675,"dzcaron":454,"dzcurl":677,"dzeabkhasiancyrillic":1249,"dzecyrillic":1109,"dzhecyrillic":1119,"e":101,"eacute":233,"earth":9793,"ebengali":2447,"ebopomofo":12572,"ebreve":277,"ecandradeva":2317,"ecandragujarati":2701,"ecandravowelsigndeva":2373,"ecandravowelsigngujarati":2757,"ecaron":283,"ecedillabreve":7709,"echarmenian":1381,"echyiwnarmenian":1415,"ecircle":9428,"ecircumflex":234,"ecircumflexacute":7871,"ecircumflexbelow":7705,"ecircumflexdotbelow":7879,"ecircumflexgrave":7873,"ecircumflexhookabove":7875,"ecircumflextilde":7877,"ecyrillic":1108,"edblgrave":517,"edeva":2319,"edieresis":235,"edot":279,"edotaccent":279,"edotbelow":7865,"eegurmukhi":2575,"eematragurmukhi":2631,"efcyrillic":1092,"egrave":232,"egujarati":2703,"eharmenian":1383,"ehbopomofo":12573,"ehiragana":12360,"ehookabove":7867,"eibopomofo":12575,"eight":56,"eightarabic":1640,"eightbengali":2542,"eightcircle":9319,"eightcircleinversesansserif":10129,"eightdeva":2414,"eighteencircle":9329,"eighteenparen":9349,"eighteenperiod":9369,"eightgujarati":2798,"eightgurmukhi":2670,"eighthackarabic":1640,"eighthangzhou":12328,"eighthnotebeamed":9835,"eightideographicparen":12839,"eightinferior":8328,"eightmonospace":65304,"eightoldstyle":63288,"eightparen":9339,"eightperiod":9359,"eightpersian":1784,"eightroman":8567,"eightsuperior":8312,"eightthai":3672,"einvertedbreve":519,"eiotifiedcyrillic":1125,"ekatakana":12456,"ekatakanahalfwidth":65396,"ekonkargurmukhi":2676,"ekorean":12628,"elcyrillic":1083,"element":8712,"elevencircle":9322,"elevenparen":9342,"elevenperiod":9362,"elevenroman":8570,"ellipsis":8230,"ellipsisvertical":8942,"emacron":275,"emacronacute":7703,"emacrongrave":7701,"emcyrillic":1084,"emdash":8212,"emdashvertical":65073,"emonospace":65349,"emphasismarkarmenian":1371,"emptyset":8709,"enbopomofo":12579,"encyrillic":1085,"endash":8211,"endashvertical":65074,"endescendercyrillic":1187,"eng":331,"engbopomofo":12581,"enghecyrillic":1189,"enhookcyrillic":1224,"enspace":8194,"eogonek":281,"eokorean":12627,"eopen":603,"eopenclosed":666,"eopenreversed":604,"eopenreversedclosed":606,"eopenreversedhook":605,"eparen":9376,"epsilon":949,"epsilontonos":941,"equal":61,"equalmonospace":65309,"equalsmall":65126,"equalsuperior":8316,"equivalence":8801,"erbopomofo":12582,"ercyrillic":1088,"ereversed":600,"ereversedcyrillic":1101,"escyrillic":1089,"esdescendercyrillic":1195,"esh":643,"eshcurl":646,"eshortdeva":2318,"eshortvowelsigndeva":2374,"eshreversedloop":426,"eshsquatreversed":645,"esmallhiragana":12359,"esmallkatakana":12455,"esmallkatakanahalfwidth":65386,"estimated":8494,"esuperior":63212,"eta":951,"etarmenian":1384,"etatonos":942,"eth":240,"etilde":7869,"etildebelow":7707,"etnahtafoukhhebrew":1425,"etnahtafoukhlefthebrew":1425,"etnahtahebrew":1425,"etnahtalefthebrew":1425,"eturned":477,"eukorean":12641,"euro":8364,"evowelsignbengali":2503,"evowelsigndeva":2375,"evowelsigngujarati":2759,"exclam":33,"exclamarmenian":1372,"exclamdbl":8252,"exclamdown":161,"exclamdownsmall":63393,"exclammonospace":65281,"exclamsmall":63265,"existential":8707,"ezh":658,"ezhcaron":495,"ezhcurl":659,"ezhreversed":441,"ezhtail":442,"f":102,"fadeva":2398,"fagurmukhi":2654,"fahrenheit":8457,"fathaarabic":1614,"fathalowarabic":1614,"fathatanarabic":1611,"fbopomofo":12552,"fcircle":9429,"fdotaccent":7711,"feharabic":1601,"feharmenian":1414,"fehfinalarabic":65234,"fehinitialarabic":65235,"fehmedialarabic":65236,"feicoptic":997,"female":9792,"ff":64256,"f_f":64256,"ffi":64259,"f_f_i":64259,"ffl":64260,"f_f_l":64260,"fi":64257,"f_i":64257,"fifteencircle":9326,"fifteenparen":9346,"fifteenperiod":9366,"figuredash":8210,"filledbox":9632,"filledrect":9644,"finalkaf":1498,"finalkafdagesh":64314,"finalkafdageshhebrew":64314,"finalkafhebrew":1498,"finalmem":1501,"finalmemhebrew":1501,"finalnun":1503,"finalnunhebrew":1503,"finalpe":1507,"finalpehebrew":1507,"finaltsadi":1509,"finaltsadihebrew":1509,"firsttonechinese":713,"fisheye":9673,"fitacyrillic":1139,"five":53,"fivearabic":1637,"fivebengali":2539,"fivecircle":9316,"fivecircleinversesansserif":10126,"fivedeva":2411,"fiveeighths":8541,"fivegujarati":2795,"fivegurmukhi":2667,"fivehackarabic":1637,"fivehangzhou":12325,"fiveideographicparen":12836,"fiveinferior":8325,"fivemonospace":65301,"fiveoldstyle":63285,"fiveparen":9336,"fiveperiod":9356,"fivepersian":1781,"fiveroman":8564,"fivesuperior":8309,"fivethai":3669,"fl":64258,"f_l":64258,"florin":402,"fmonospace":65350,"fmsquare":13209,"fofanthai":3615,"fofathai":3613,"fongmanthai":3663,"forall":8704,"four":52,"fourarabic":1636,"fourbengali":2538,"fourcircle":9315,"fourcircleinversesansserif":10125,"fourdeva":2410,"fourgujarati":2794,"fourgurmukhi":2666,"fourhackarabic":1636,"fourhangzhou":12324,"fourideographicparen":12835,"fourinferior":8324,"fourmonospace":65300,"fournumeratorbengali":2551,"fouroldstyle":63284,"fourparen":9335,"fourperiod":9355,"fourpersian":1780,"fourroman":8563,"foursuperior":8308,"fourteencircle":9325,"fourteenparen":9345,"fourteenperiod":9365,"fourthai":3668,"fourthtonechinese":715,"fparen":9377,"fraction":8260,"franc":8355,"g":103,"gabengali":2455,"gacute":501,"gadeva":2327,"gafarabic":1711,"gaffinalarabic":64403,"gafinitialarabic":64404,"gafmedialarabic":64405,"gagujarati":2711,"gagurmukhi":2583,"gahiragana":12364,"gakatakana":12460,"gamma":947,"gammalatinsmall":611,"gammasuperior":736,"gangiacoptic":1003,"gbopomofo":12557,"gbreve":287,"gcaron":487,"gcedilla":291,"gcircle":9430,"gcircumflex":285,"gcommaaccent":291,"gdot":289,"gdotaccent":289,"gecyrillic":1075,"gehiragana":12370,"gekatakana":12466,"geometricallyequal":8785,"gereshaccenthebrew":1436,"gereshhebrew":1523,"gereshmuqdamhebrew":1437,"germandbls":223,"gershayimaccenthebrew":1438,"gershayimhebrew":1524,"getamark":12307,"ghabengali":2456,"ghadarmenian":1394,"ghadeva":2328,"ghagujarati":2712,"ghagurmukhi":2584,"ghainarabic":1594,"ghainfinalarabic":65230,"ghaininitialarabic":65231,"ghainmedialarabic":65232,"ghemiddlehookcyrillic":1173,"ghestrokecyrillic":1171,"gheupturncyrillic":1169,"ghhadeva":2394,"ghhagurmukhi":2650,"ghook":608,"ghzsquare":13203,"gihiragana":12366,"gikatakana":12462,"gimarmenian":1379,"gimel":1490,"gimeldagesh":64306,"gimeldageshhebrew":64306,"gimelhebrew":1490,"gjecyrillic":1107,"glottalinvertedstroke":446,"glottalstop":660,"glottalstopinverted":662,"glottalstopmod":704,"glottalstopreversed":661,"glottalstopreversedmod":705,"glottalstopreversedsuperior":740,"glottalstopstroke":673,"glottalstopstrokereversed":674,"gmacron":7713,"gmonospace":65351,"gohiragana":12372,"gokatakana":12468,"gparen":9378,"gpasquare":13228,"gradient":8711,"grave":96,"gravebelowcmb":790,"gravecmb":768,"gravecomb":768,"gravedeva":2387,"gravelowmod":718,"gravemonospace":65344,"gravetonecmb":832,"greater":62,"greaterequal":8805,"greaterequalorless":8923,"greatermonospace":65310,"greaterorequivalent":8819,"greaterorless":8823,"greateroverequal":8807,"greatersmall":65125,"gscript":609,"gstroke":485,"guhiragana":12368,"guillemotleft":171,"guillemotright":187,"guilsinglleft":8249,"guilsinglright":8250,"gukatakana":12464,"guramusquare":13080,"gysquare":13257,"h":104,"haabkhasiancyrillic":1193,"haaltonearabic":1729,"habengali":2489,"hadescendercyrillic":1203,"hadeva":2361,"hagujarati":2745,"hagurmukhi":2617,"haharabic":1581,"hahfinalarabic":65186,"hahinitialarabic":65187,"hahiragana":12399,"hahmedialarabic":65188,"haitusquare":13098,"hakatakana":12495,"hakatakanahalfwidth":65418,"halantgurmukhi":2637,"hamzaarabic":1569,"hamzalowarabic":1569,"hangulfiller":12644,"hardsigncyrillic":1098,"harpoonleftbarbup":8636,"harpoonrightbarbup":8640,"hasquare":13258,"hatafpatah":1458,"hatafpatah16":1458,"hatafpatah23":1458,"hatafpatah2f":1458,"hatafpatahhebrew":1458,"hatafpatahnarrowhebrew":1458,"hatafpatahquarterhebrew":1458,"hatafpatahwidehebrew":1458,"hatafqamats":1459,"hatafqamats1b":1459,"hatafqamats28":1459,"hatafqamats34":1459,"hatafqamatshebrew":1459,"hatafqamatsnarrowhebrew":1459,"hatafqamatsquarterhebrew":1459,"hatafqamatswidehebrew":1459,"hatafsegol":1457,"hatafsegol17":1457,"hatafsegol24":1457,"hatafsegol30":1457,"hatafsegolhebrew":1457,"hatafsegolnarrowhebrew":1457,"hatafsegolquarterhebrew":1457,"hatafsegolwidehebrew":1457,"hbar":295,"hbopomofo":12559,"hbrevebelow":7723,"hcedilla":7721,"hcircle":9431,"hcircumflex":293,"hdieresis":7719,"hdotaccent":7715,"hdotbelow":7717,"he":1492,"heart":9829,"heartsuitblack":9829,"heartsuitwhite":9825,"hedagesh":64308,"hedageshhebrew":64308,"hehaltonearabic":1729,"heharabic":1607,"hehebrew":1492,"hehfinalaltonearabic":64423,"hehfinalalttwoarabic":65258,"hehfinalarabic":65258,"hehhamzaabovefinalarabic":64421,"hehhamzaaboveisolatedarabic":64420,"hehinitialaltonearabic":64424,"hehinitialarabic":65259,"hehiragana":12408,"hehmedialaltonearabic":64425,"hehmedialarabic":65260,"heiseierasquare":13179,"hekatakana":12504,"hekatakanahalfwidth":65421,"hekutaarusquare":13110,"henghook":615,"herutusquare":13113,"het":1495,"hethebrew":1495,"hhook":614,"hhooksuperior":689,"hieuhacirclekorean":12923,"hieuhaparenkorean":12827,"hieuhcirclekorean":12909,"hieuhkorean":12622,"hieuhparenkorean":12813,"hihiragana":12402,"hikatakana":12498,"hikatakanahalfwidth":65419,"hiriq":1460,"hiriq14":1460,"hiriq21":1460,"hiriq2d":1460,"hiriqhebrew":1460,"hiriqnarrowhebrew":1460,"hiriqquarterhebrew":1460,"hiriqwidehebrew":1460,"hlinebelow":7830,"hmonospace":65352,"hoarmenian":1392,"hohipthai":3627,"hohiragana":12411,"hokatakana":12507,"hokatakanahalfwidth":65422,"holam":1465,"holam19":1465,"holam26":1465,"holam32":1465,"holamhebrew":1465,"holamnarrowhebrew":1465,"holamquarterhebrew":1465,"holamwidehebrew":1465,"honokhukthai":3630,"hookabovecomb":777,"hookcmb":777,"hookpalatalizedbelowcmb":801,"hookretroflexbelowcmb":802,"hoonsquare":13122,"horicoptic":1001,"horizontalbar":8213,"horncmb":795,"hotsprings":9832,"house":8962,"hparen":9379,"hsuperior":688,"hturned":613,"huhiragana":12405,"huiitosquare":13107,"hukatakana":12501,"hukatakanahalfwidth":65420,"hungarumlaut":733,"hungarumlautcmb":779,"hv":405,"hyphen":45,"hypheninferior":63205,"hyphenmonospace":65293,"hyphensmall":65123,"hyphensuperior":63206,"hyphentwo":8208,"i":105,"iacute":237,"iacyrillic":1103,"ibengali":2439,"ibopomofo":12583,"ibreve":301,"icaron":464,"icircle":9432,"icircumflex":238,"icyrillic":1110,"idblgrave":521,"ideographearthcircle":12943,"ideographfirecircle":12939,"ideographicallianceparen":12863,"ideographiccallparen":12858,"ideographiccentrecircle":12965,"ideographicclose":12294,"ideographiccomma":12289,"ideographiccommaleft":65380,"ideographiccongratulationparen":12855,"ideographiccorrectcircle":12963,"ideographicearthparen":12847,"ideographicenterpriseparen":12861,"ideographicexcellentcircle":12957,"ideographicfestivalparen":12864,"ideographicfinancialcircle":12950,"ideographicfinancialparen":12854,"ideographicfireparen":12843,"ideographichaveparen":12850,"ideographichighcircle":12964,"ideographiciterationmark":12293,"ideographiclaborcircle":12952,"ideographiclaborparen":12856,"ideographicleftcircle":12967,"ideographiclowcircle":12966,"ideographicmedicinecircle":12969,"ideographicmetalparen":12846,"ideographicmoonparen":12842,"ideographicnameparen":12852,"ideographicperiod":12290,"ideographicprintcircle":12958,"ideographicreachparen":12867,"ideographicrepresentparen":12857,"ideographicresourceparen":12862,"ideographicrightcircle":12968,"ideographicsecretcircle":12953,"ideographicselfparen":12866,"ideographicsocietyparen":12851,"ideographicspace":12288,"ideographicspecialparen":12853,"ideographicstockparen":12849,"ideographicstudyparen":12859,"ideographicsunparen":12848,"ideographicsuperviseparen":12860,"ideographicwaterparen":12844,"ideographicwoodparen":12845,"ideographiczero":12295,"ideographmetalcircle":12942,"ideographmooncircle":12938,"ideographnamecircle":12948,"ideographsuncircle":12944,"ideographwatercircle":12940,"ideographwoodcircle":12941,"ideva":2311,"idieresis":239,"idieresisacute":7727,"idieresiscyrillic":1253,"idotbelow":7883,"iebrevecyrillic":1239,"iecyrillic":1077,"ieungacirclekorean":12917,"ieungaparenkorean":12821,"ieungcirclekorean":12903,"ieungkorean":12615,"ieungparenkorean":12807,"igrave":236,"igujarati":2695,"igurmukhi":2567,"ihiragana":12356,"ihookabove":7881,"iibengali":2440,"iicyrillic":1080,"iideva":2312,"iigujarati":2696,"iigurmukhi":2568,"iimatragurmukhi":2624,"iinvertedbreve":523,"iishortcyrillic":1081,"iivowelsignbengali":2496,"iivowelsigndeva":2368,"iivowelsigngujarati":2752,"ij":307,"ikatakana":12452,"ikatakanahalfwidth":65394,"ikorean":12643,"ilde":732,"iluyhebrew":1452,"imacron":299,"imacroncyrillic":1251,"imageorapproximatelyequal":8787,"imatragurmukhi":2623,"imonospace":65353,"increment":8710,"infinity":8734,"iniarmenian":1387,"integral":8747,"integralbottom":8993,"integralbt":8993,"integralex":63733,"integraltop":8992,"integraltp":8992,"intersection":8745,"intisquare":13061,"invbullet":9688,"invcircle":9689,"invsmileface":9787,"iocyrillic":1105,"iogonek":303,"iota":953,"iotadieresis":970,"iotadieresistonos":912,"iotalatin":617,"iotatonos":943,"iparen":9380,"irigurmukhi":2674,"ismallhiragana":12355,"ismallkatakana":12451,"ismallkatakanahalfwidth":65384,"issharbengali":2554,"istroke":616,"isuperior":63213,"iterationhiragana":12445,"iterationkatakana":12541,"itilde":297,"itildebelow":7725,"iubopomofo":12585,"iucyrillic":1102,"ivowelsignbengali":2495,"ivowelsigndeva":2367,"ivowelsigngujarati":2751,"izhitsacyrillic":1141,"izhitsadblgravecyrillic":1143,"j":106,"jaarmenian":1393,"jabengali":2460,"jadeva":2332,"jagujarati":2716,"jagurmukhi":2588,"jbopomofo":12560,"jcaron":496,"jcircle":9433,"jcircumflex":309,"jcrossedtail":669,"jdotlessstroke":607,"jecyrillic":1112,"jeemarabic":1580,"jeemfinalarabic":65182,"jeeminitialarabic":65183,"jeemmedialarabic":65184,"jeharabic":1688,"jehfinalarabic":64395,"jhabengali":2461,"jhadeva":2333,"jhagujarati":2717,"jhagurmukhi":2589,"jheharmenian":1403,"jis":12292,"jmonospace":65354,"jparen":9381,"jsuperior":690,"k":107,"kabashkircyrillic":1185,"kabengali":2453,"kacute":7729,"kacyrillic":1082,"kadescendercyrillic":1179,"kadeva":2325,"kaf":1499,"kafarabic":1603,"kafdagesh":64315,"kafdageshhebrew":64315,"kaffinalarabic":65242,"kafhebrew":1499,"kafinitialarabic":65243,"kafmedialarabic":65244,"kafrafehebrew":64333,"kagujarati":2709,"kagurmukhi":2581,"kahiragana":12363,"kahookcyrillic":1220,"kakatakana":12459,"kakatakanahalfwidth":65398,"kappa":954,"kappasymbolgreek":1008,"kapyeounmieumkorean":12657,"kapyeounphieuphkorean":12676,"kapyeounpieupkorean":12664,"kapyeounssangpieupkorean":12665,"karoriisquare":13069,"kashidaautoarabic":1600,"kashidaautonosidebearingarabic":1600,"kasmallkatakana":12533,"kasquare":13188,"kasraarabic":1616,"kasratanarabic":1613,"kastrokecyrillic":1183,"katahiraprolongmarkhalfwidth":65392,"kaverticalstrokecyrillic":1181,"kbopomofo":12558,"kcalsquare":13193,"kcaron":489,"kcedilla":311,"kcircle":9434,"kcommaaccent":311,"kdotbelow":7731,"keharmenian":1412,"kehiragana":12369,"kekatakana":12465,"kekatakanahalfwidth":65401,"kenarmenian":1391,"kesmallkatakana":12534,"kgreenlandic":312,"khabengali":2454,"khacyrillic":1093,"khadeva":2326,"khagujarati":2710,"khagurmukhi":2582,"khaharabic":1582,"khahfinalarabic":65190,"khahinitialarabic":65191,"khahmedialarabic":65192,"kheicoptic":999,"khhadeva":2393,"khhagurmukhi":2649,"khieukhacirclekorean":12920,"khieukhaparenkorean":12824,"khieukhcirclekorean":12906,"khieukhkorean":12619,"khieukhparenkorean":12810,"khokhaithai":3586,"khokhonthai":3589,"khokhuatthai":3587,"khokhwaithai":3588,"khomutthai":3675,"khook":409,"khorakhangthai":3590,"khzsquare":13201,"kihiragana":12365,"kikatakana":12461,"kikatakanahalfwidth":65399,"kiroguramusquare":13077,"kiromeetorusquare":13078,"kirosquare":13076,"kiyeokacirclekorean":12910,"kiyeokaparenkorean":12814,"kiyeokcirclekorean":12896,"kiyeokkorean":12593,"kiyeokparenkorean":12800,"kiyeoksioskorean":12595,"kjecyrillic":1116,"klinebelow":7733,"klsquare":13208,"kmcubedsquare":13222,"kmonospace":65355,"kmsquaredsquare":13218,"kohiragana":12371,"kohmsquare":13248,"kokaithai":3585,"kokatakana":12467,"kokatakanahalfwidth":65402,"kooposquare":13086,"koppacyrillic":1153,"koreanstandardsymbol":12927,"koroniscmb":835,"kparen":9382,"kpasquare":13226,"ksicyrillic":1135,"ktsquare":13263,"kturned":670,"kuhiragana":12367,"kukatakana":12463,"kukatakanahalfwidth":65400,"kvsquare":13240,"kwsquare":13246,"l":108,"labengali":2482,"lacute":314,"ladeva":2354,"lagujarati":2738,"lagurmukhi":2610,"lakkhangyaothai":3653,"lamaleffinalarabic":65276,"lamalefhamzaabovefinalarabic":65272,"lamalefhamzaaboveisolatedarabic":65271,"lamalefhamzabelowfinalarabic":65274,"lamalefhamzabelowisolatedarabic":65273,"lamalefisolatedarabic":65275,"lamalefmaddaabovefinalarabic":65270,"lamalefmaddaaboveisolatedarabic":65269,"lamarabic":1604,"lambda":955,"lambdastroke":411,"lamed":1500,"lameddagesh":64316,"lameddageshhebrew":64316,"lamedhebrew":1500,"lamfinalarabic":65246,"lamhahinitialarabic":64714,"laminitialarabic":65247,"lamjeeminitialarabic":64713,"lamkhahinitialarabic":64715,"lamlamhehisolatedarabic":65010,"lammedialarabic":65248,"lammeemhahinitialarabic":64904,"lammeeminitialarabic":64716,"largecircle":9711,"lbar":410,"lbelt":620,"lbopomofo":12556,"lcaron":318,"lcedilla":316,"lcircle":9435,"lcircumflexbelow":7741,"lcommaaccent":316,"ldot":320,"ldotaccent":320,"ldotbelow":7735,"ldotbelowmacron":7737,"leftangleabovecmb":794,"lefttackbelowcmb":792,"less":60,"lessequal":8804,"lessequalorgreater":8922,"lessmonospace":65308,"lessorequivalent":8818,"lessorgreater":8822,"lessoverequal":8806,"lesssmall":65124,"lezh":622,"lfblock":9612,"lhookretroflex":621,"lira":8356,"liwnarmenian":1388,"lj":457,"ljecyrillic":1113,"ll":63168,"lladeva":2355,"llagujarati":2739,"llinebelow":7739,"llladeva":2356,"llvocalicbengali":2529,"llvocalicdeva":2401,"llvocalicvowelsignbengali":2531,"llvocalicvowelsigndeva":2403,"lmiddletilde":619,"lmonospace":65356,"lmsquare":13264,"lochulathai":3628,"logicaland":8743,"logicalnot":172,"logicalnotreversed":8976,"logicalor":8744,"lolingthai":3621,"longs":383,"lowlinecenterline":65102,"lowlinecmb":818,"lowlinedashed":65101,"lozenge":9674,"lparen":9383,"lslash":322,"lsquare":8467,"lsuperior":63214,"ltshade":9617,"luthai":3622,"lvocalicbengali":2444,"lvocalicdeva":2316,"lvocalicvowelsignbengali":2530,"lvocalicvowelsigndeva":2402,"lxsquare":13267,"m":109,"mabengali":2478,"macron":175,"macronbelowcmb":817,"macroncmb":772,"macronlowmod":717,"macronmonospace":65507,"macute":7743,"madeva":2350,"magujarati":2734,"magurmukhi":2606,"mahapakhhebrew":1444,"mahapakhlefthebrew":1444,"mahiragana":12414,"maichattawalowleftthai":63637,"maichattawalowrightthai":63636,"maichattawathai":3659,"maichattawaupperleftthai":63635,"maieklowleftthai":63628,"maieklowrightthai":63627,"maiekthai":3656,"maiekupperleftthai":63626,"maihanakatleftthai":63620,"maihanakatthai":3633,"maitaikhuleftthai":63625,"maitaikhuthai":3655,"maitholowleftthai":63631,"maitholowrightthai":63630,"maithothai":3657,"maithoupperleftthai":63629,"maitrilowleftthai":63634,"maitrilowrightthai":63633,"maitrithai":3658,"maitriupperleftthai":63632,"maiyamokthai":3654,"makatakana":12510,"makatakanahalfwidth":65423,"male":9794,"mansyonsquare":13127,"maqafhebrew":1470,"mars":9794,"masoracirclehebrew":1455,"masquare":13187,"mbopomofo":12551,"mbsquare":13268,"mcircle":9436,"mcubedsquare":13221,"mdotaccent":7745,"mdotbelow":7747,"meemarabic":1605,"meemfinalarabic":65250,"meeminitialarabic":65251,"meemmedialarabic":65252,"meemmeeminitialarabic":64721,"meemmeemisolatedarabic":64584,"meetorusquare":13133,"mehiragana":12417,"meizierasquare":13182,"mekatakana":12513,"mekatakanahalfwidth":65426,"mem":1502,"memdagesh":64318,"memdageshhebrew":64318,"memhebrew":1502,"menarmenian":1396,"merkhahebrew":1445,"merkhakefulahebrew":1446,"merkhakefulalefthebrew":1446,"merkhalefthebrew":1445,"mhook":625,"mhzsquare":13202,"middledotkatakanahalfwidth":65381,"middot":183,"mieumacirclekorean":12914,"mieumaparenkorean":12818,"mieumcirclekorean":12900,"mieumkorean":12609,"mieumpansioskorean":12656,"mieumparenkorean":12804,"mieumpieupkorean":12654,"mieumsioskorean":12655,"mihiragana":12415,"mikatakana":12511,"mikatakanahalfwidth":65424,"minus":8722,"minusbelowcmb":800,"minuscircle":8854,"minusmod":727,"minusplus":8723,"minute":8242,"miribaarusquare":13130,"mirisquare":13129,"mlonglegturned":624,"mlsquare":13206,"mmcubedsquare":13219,"mmonospace":65357,"mmsquaredsquare":13215,"mohiragana":12418,"mohmsquare":13249,"mokatakana":12514,"mokatakanahalfwidth":65427,"molsquare":13270,"momathai":3617,"moverssquare":13223,"moverssquaredsquare":13224,"mparen":9384,"mpasquare":13227,"mssquare":13235,"msuperior":63215,"mturned":623,"mu":181,"mu1":181,"muasquare":13186,"muchgreater":8811,"muchless":8810,"mufsquare":13196,"mugreek":956,"mugsquare":13197,"muhiragana":12416,"mukatakana":12512,"mukatakanahalfwidth":65425,"mulsquare":13205,"multiply":215,"mumsquare":13211,"munahhebrew":1443,"munahlefthebrew":1443,"musicalnote":9834,"musicalnotedbl":9835,"musicflatsign":9837,"musicsharpsign":9839,"mussquare":13234,"muvsquare":13238,"muwsquare":13244,"mvmegasquare":13241,"mvsquare":13239,"mwmegasquare":13247,"mwsquare":13245,"n":110,"nabengali":2472,"nabla":8711,"nacute":324,"nadeva":2344,"nagujarati":2728,"nagurmukhi":2600,"nahiragana":12394,"nakatakana":12490,"nakatakanahalfwidth":65413,"napostrophe":329,"nasquare":13185,"nbopomofo":12555,"nbspace":160,"ncaron":328,"ncedilla":326,"ncircle":9437,"ncircumflexbelow":7755,"ncommaaccent":326,"ndotaccent":7749,"ndotbelow":7751,"nehiragana":12397,"nekatakana":12493,"nekatakanahalfwidth":65416,"newsheqelsign":8362,"nfsquare":13195,"ngabengali":2457,"ngadeva":2329,"ngagujarati":2713,"ngagurmukhi":2585,"ngonguthai":3591,"nhiragana":12435,"nhookleft":626,"nhookretroflex":627,"nieunacirclekorean":12911,"nieunaparenkorean":12815,"nieuncieuckorean":12597,"nieuncirclekorean":12897,"nieunhieuhkorean":12598,"nieunkorean":12596,"nieunpansioskorean":12648,"nieunparenkorean":12801,"nieunsioskorean":12647,"nieuntikeutkorean":12646,"nihiragana":12395,"nikatakana":12491,"nikatakanahalfwidth":65414,"nikhahitleftthai":63641,"nikhahitthai":3661,"nine":57,"ninearabic":1641,"ninebengali":2543,"ninecircle":9320,"ninecircleinversesansserif":10130,"ninedeva":2415,"ninegujarati":2799,"ninegurmukhi":2671,"ninehackarabic":1641,"ninehangzhou":12329,"nineideographicparen":12840,"nineinferior":8329,"ninemonospace":65305,"nineoldstyle":63289,"nineparen":9340,"nineperiod":9360,"ninepersian":1785,"nineroman":8568,"ninesuperior":8313,"nineteencircle":9330,"nineteenparen":9350,"nineteenperiod":9370,"ninethai":3673,"nj":460,"njecyrillic":1114,"nkatakana":12531,"nkatakanahalfwidth":65437,"nlegrightlong":414,"nlinebelow":7753,"nmonospace":65358,"nmsquare":13210,"nnabengali":2467,"nnadeva":2339,"nnagujarati":2723,"nnagurmukhi":2595,"nnnadeva":2345,"nohiragana":12398,"nokatakana":12494,"nokatakanahalfwidth":65417,"nonbreakingspace":160,"nonenthai":3603,"nonuthai":3609,"noonarabic":1606,"noonfinalarabic":65254,"noonghunnaarabic":1722,"noonghunnafinalarabic":64415,"nooninitialarabic":65255,"noonjeeminitialarabic":64722,"noonjeemisolatedarabic":64587,"noonmedialarabic":65256,"noonmeeminitialarabic":64725,"noonmeemisolatedarabic":64590,"noonnoonfinalarabic":64653,"notcontains":8716,"notelement":8713,"notelementof":8713,"notequal":8800,"notgreater":8815,"notgreaternorequal":8817,"notgreaternorless":8825,"notidentical":8802,"notless":8814,"notlessnorequal":8816,"notparallel":8742,"notprecedes":8832,"notsubset":8836,"notsucceeds":8833,"notsuperset":8837,"nowarmenian":1398,"nparen":9385,"nssquare":13233,"nsuperior":8319,"ntilde":241,"nu":957,"nuhiragana":12396,"nukatakana":12492,"nukatakanahalfwidth":65415,"nuktabengali":2492,"nuktadeva":2364,"nuktagujarati":2748,"nuktagurmukhi":2620,"numbersign":35,"numbersignmonospace":65283,"numbersignsmall":65119,"numeralsigngreek":884,"numeralsignlowergreek":885,"numero":8470,"nun":1504,"nundagesh":64320,"nundageshhebrew":64320,"nunhebrew":1504,"nvsquare":13237,"nwsquare":13243,"nyabengali":2462,"nyadeva":2334,"nyagujarati":2718,"nyagurmukhi":2590,"o":111,"oacute":243,"oangthai":3629,"obarred":629,"obarredcyrillic":1257,"obarreddieresiscyrillic":1259,"obengali":2451,"obopomofo":12571,"obreve":335,"ocandradeva":2321,"ocandragujarati":2705,"ocandravowelsigndeva":2377,"ocandravowelsigngujarati":2761,"ocaron":466,"ocircle":9438,"ocircumflex":244,"ocircumflexacute":7889,"ocircumflexdotbelow":7897,"ocircumflexgrave":7891,"ocircumflexhookabove":7893,"ocircumflextilde":7895,"ocyrillic":1086,"odblacute":337,"odblgrave":525,"odeva":2323,"odieresis":246,"odieresiscyrillic":1255,"odotbelow":7885,"oe":339,"oekorean":12634,"ogonek":731,"ogonekcmb":808,"ograve":242,"ogujarati":2707,"oharmenian":1413,"ohiragana":12362,"ohookabove":7887,"ohorn":417,"ohornacute":7899,"ohorndotbelow":7907,"ohorngrave":7901,"ohornhookabove":7903,"ohorntilde":7905,"ohungarumlaut":337,"oi":419,"oinvertedbreve":527,"okatakana":12458,"okatakanahalfwidth":65397,"okorean":12631,"olehebrew":1451,"omacron":333,"omacronacute":7763,"omacrongrave":7761,"omdeva":2384,"omega":969,"omega1":982,"omegacyrillic":1121,"omegalatinclosed":631,"omegaroundcyrillic":1147,"omegatitlocyrillic":1149,"omegatonos":974,"omgujarati":2768,"omicron":959,"omicrontonos":972,"omonospace":65359,"one":49,"onearabic":1633,"onebengali":2535,"onecircle":9312,"onecircleinversesansserif":10122,"onedeva":2407,"onedotenleader":8228,"oneeighth":8539,"onefitted":63196,"onegujarati":2791,"onegurmukhi":2663,"onehackarabic":1633,"onehalf":189,"onehangzhou":12321,"oneideographicparen":12832,"oneinferior":8321,"onemonospace":65297,"onenumeratorbengali":2548,"oneoldstyle":63281,"oneparen":9332,"oneperiod":9352,"onepersian":1777,"onequarter":188,"oneroman":8560,"onesuperior":185,"onethai":3665,"onethird":8531,"oogonek":491,"oogonekmacron":493,"oogurmukhi":2579,"oomatragurmukhi":2635,"oopen":596,"oparen":9386,"openbullet":9702,"option":8997,"ordfeminine":170,"ordmasculine":186,"orthogonal":8735,"oshortdeva":2322,"oshortvowelsigndeva":2378,"oslash":248,"oslashacute":511,"osmallhiragana":12361,"osmallkatakana":12457,"osmallkatakanahalfwidth":65387,"ostrokeacute":511,"osuperior":63216,"otcyrillic":1151,"otilde":245,"otildeacute":7757,"otildedieresis":7759,"oubopomofo":12577,"overline":8254,"overlinecenterline":65098,"overlinecmb":773,"overlinedashed":65097,"overlinedblwavy":65100,"overlinewavy":65099,"overscore":175,"ovowelsignbengali":2507,"ovowelsigndeva":2379,"ovowelsigngujarati":2763,"p":112,"paampssquare":13184,"paasentosquare":13099,"pabengali":2474,"pacute":7765,"padeva":2346,"pagedown":8671,"pageup":8670,"pagujarati":2730,"pagurmukhi":2602,"pahiragana":12401,"paiyannoithai":3631,"pakatakana":12497,"palatalizationcyrilliccmb":1156,"palochkacyrillic":1216,"pansioskorean":12671,"paragraph":182,"parallel":8741,"parenleft":40,"parenleftaltonearabic":64830,"parenleftbt":63725,"parenleftex":63724,"parenleftinferior":8333,"parenleftmonospace":65288,"parenleftsmall":65113,"parenleftsuperior":8317,"parenlefttp":63723,"parenleftvertical":65077,"parenright":41,"parenrightaltonearabic":64831,"parenrightbt":63736,"parenrightex":63735,"parenrightinferior":8334,"parenrightmonospace":65289,"parenrightsmall":65114,"parenrightsuperior":8318,"parenrighttp":63734,"parenrightvertical":65078,"partialdiff":8706,"paseqhebrew":1472,"pashtahebrew":1433,"pasquare":13225,"patah":1463,"patah11":1463,"patah1d":1463,"patah2a":1463,"patahhebrew":1463,"patahnarrowhebrew":1463,"patahquarterhebrew":1463,"patahwidehebrew":1463,"pazerhebrew":1441,"pbopomofo":12550,"pcircle":9439,"pdotaccent":7767,"pe":1508,"pecyrillic":1087,"pedagesh":64324,"pedageshhebrew":64324,"peezisquare":13115,"pefinaldageshhebrew":64323,"peharabic":1662,"peharmenian":1402,"pehebrew":1508,"pehfinalarabic":64343,"pehinitialarabic":64344,"pehiragana":12410,"pehmedialarabic":64345,"pekatakana":12506,"pemiddlehookcyrillic":1191,"perafehebrew":64334,"percent":37,"percentarabic":1642,"percentmonospace":65285,"percentsmall":65130,"period":46,"periodarmenian":1417,"periodcentered":183,"periodhalfwidth":65377,"periodinferior":63207,"periodmonospace":65294,"periodsmall":65106,"periodsuperior":63208,"perispomenigreekcmb":834,"perpendicular":8869,"perthousand":8240,"peseta":8359,"pfsquare":13194,"phabengali":2475,"phadeva":2347,"phagujarati":2731,"phagurmukhi":2603,"phi":966,"phi1":981,"phieuphacirclekorean":12922,"phieuphaparenkorean":12826,"phieuphcirclekorean":12908,"phieuphkorean":12621,"phieuphparenkorean":12812,"philatin":632,"phinthuthai":3642,"phisymbolgreek":981,"phook":421,"phophanthai":3614,"phophungthai":3612,"phosamphaothai":3616,"pi":960,"pieupacirclekorean":12915,"pieupaparenkorean":12819,"pieupcieuckorean":12662,"pieupcirclekorean":12901,"pieupkiyeokkorean":12658,"pieupkorean":12610,"pieupparenkorean":12805,"pieupsioskiyeokkorean":12660,"pieupsioskorean":12612,"pieupsiostikeutkorean":12661,"pieupthieuthkorean":12663,"pieuptikeutkorean":12659,"pihiragana":12404,"pikatakana":12500,"pisymbolgreek":982,"piwrarmenian":1411,"plus":43,"plusbelowcmb":799,"pluscircle":8853,"plusminus":177,"plusmod":726,"plusmonospace":65291,"plussmall":65122,"plussuperior":8314,"pmonospace":65360,"pmsquare":13272,"pohiragana":12413,"pointingindexdownwhite":9759,"pointingindexleftwhite":9756,"pointingindexrightwhite":9758,"pointingindexupwhite":9757,"pokatakana":12509,"poplathai":3611,"postalmark":12306,"postalmarkface":12320,"pparen":9387,"precedes":8826,"prescription":8478,"primemod":697,"primereversed":8245,"product":8719,"projective":8965,"prolongedkana":12540,"propellor":8984,"propersubset":8834,"propersuperset":8835,"proportion":8759,"proportional":8733,"psi":968,"psicyrillic":1137,"psilipneumatacyrilliccmb":1158,"pssquare":13232,"puhiragana":12407,"pukatakana":12503,"pvsquare":13236,"pwsquare":13242,"q":113,"qadeva":2392,"qadmahebrew":1448,"qafarabic":1602,"qaffinalarabic":65238,"qafinitialarabic":65239,"qafmedialarabic":65240,"qamats":1464,"qamats10":1464,"qamats1a":1464,"qamats1c":1464,"qamats27":1464,"qamats29":1464,"qamats33":1464,"qamatsde":1464,"qamatshebrew":1464,"qamatsnarrowhebrew":1464,"qamatsqatanhebrew":1464,"qamatsqatannarrowhebrew":1464,"qamatsqatanquarterhebrew":1464,"qamatsqatanwidehebrew":1464,"qamatsquarterhebrew":1464,"qamatswidehebrew":1464,"qarneyparahebrew":1439,"qbopomofo":12561,"qcircle":9440,"qhook":672,"qmonospace":65361,"qof":1511,"qofdagesh":64327,"qofdageshhebrew":64327,"qofhebrew":1511,"qparen":9388,"quarternote":9833,"qubuts":1467,"qubuts18":1467,"qubuts25":1467,"qubuts31":1467,"qubutshebrew":1467,"qubutsnarrowhebrew":1467,"qubutsquarterhebrew":1467,"qubutswidehebrew":1467,"question":63,"questionarabic":1567,"questionarmenian":1374,"questiondown":191,"questiondownsmall":63423,"questiongreek":894,"questionmonospace":65311,"questionsmall":63295,"quotedbl":34,"quotedblbase":8222,"quotedblleft":8220,"quotedblmonospace":65282,"quotedblprime":12318,"quotedblprimereversed":12317,"quotedblright":8221,"quoteleft":8216,"quoteleftreversed":8219,"quotereversed":8219,"quoteright":8217,"quoterightn":329,"quotesinglbase":8218,"quotesingle":39,"quotesinglemonospace":65287,"r":114,"raarmenian":1404,"rabengali":2480,"racute":341,"radeva":2352,"radical":8730,"radicalex":63717,"radoverssquare":13230,"radoverssquaredsquare":13231,"radsquare":13229,"rafe":1471,"rafehebrew":1471,"ragujarati":2736,"ragurmukhi":2608,"rahiragana":12425,"rakatakana":12521,"rakatakanahalfwidth":65431,"ralowerdiagonalbengali":2545,"ramiddlediagonalbengali":2544,"ramshorn":612,"ratio":8758,"rbopomofo":12566,"rcaron":345,"rcedilla":343,"rcircle":9441,"rcommaaccent":343,"rdblgrave":529,"rdotaccent":7769,"rdotbelow":7771,"rdotbelowmacron":7773,"referencemark":8251,"reflexsubset":8838,"reflexsuperset":8839,"registered":174,"registersans":63720,"registerserif":63194,"reharabic":1585,"reharmenian":1408,"rehfinalarabic":65198,"rehiragana":12428,"rekatakana":12524,"rekatakanahalfwidth":65434,"resh":1512,"reshdageshhebrew":64328,"reshhebrew":1512,"reversedtilde":8765,"reviahebrew":1431,"reviamugrashhebrew":1431,"revlogicalnot":8976,"rfishhook":638,"rfishhookreversed":639,"rhabengali":2525,"rhadeva":2397,"rho":961,"rhook":637,"rhookturned":635,"rhookturnedsuperior":693,"rhosymbolgreek":1009,"rhotichookmod":734,"rieulacirclekorean":12913,"rieulaparenkorean":12817,"rieulcirclekorean":12899,"rieulhieuhkorean":12608,"rieulkiyeokkorean":12602,"rieulkiyeoksioskorean":12649,"rieulkorean":12601,"rieulmieumkorean":12603,"rieulpansioskorean":12652,"rieulparenkorean":12803,"rieulphieuphkorean":12607,"rieulpieupkorean":12604,"rieulpieupsioskorean":12651,"rieulsioskorean":12605,"rieulthieuthkorean":12606,"rieultikeutkorean":12650,"rieulyeorinhieuhkorean":12653,"rightangle":8735,"righttackbelowcmb":793,"righttriangle":8895,"rihiragana":12426,"rikatakana":12522,"rikatakanahalfwidth":65432,"ring":730,"ringbelowcmb":805,"ringcmb":778,"ringhalfleft":703,"ringhalfleftarmenian":1369,"ringhalfleftbelowcmb":796,"ringhalfleftcentered":723,"ringhalfright":702,"ringhalfrightbelowcmb":825,"ringhalfrightcentered":722,"rinvertedbreve":531,"rittorusquare":13137,"rlinebelow":7775,"rlongleg":636,"rlonglegturned":634,"rmonospace":65362,"rohiragana":12429,"rokatakana":12525,"rokatakanahalfwidth":65435,"roruathai":3619,"rparen":9389,"rrabengali":2524,"rradeva":2353,"rragurmukhi":2652,"rreharabic":1681,"rrehfinalarabic":64397,"rrvocalicbengali":2528,"rrvocalicdeva":2400,"rrvocalicgujarati":2784,"rrvocalicvowelsignbengali":2500,"rrvocalicvowelsigndeva":2372,"rrvocalicvowelsigngujarati":2756,"rsuperior":63217,"rtblock":9616,"rturned":633,"rturnedsuperior":692,"ruhiragana":12427,"rukatakana":12523,"rukatakanahalfwidth":65433,"rupeemarkbengali":2546,"rupeesignbengali":2547,"rupiah":63197,"ruthai":3620,"rvocalicbengali":2443,"rvocalicdeva":2315,"rvocalicgujarati":2699,"rvocalicvowelsignbengali":2499,"rvocalicvowelsigndeva":2371,"rvocalicvowelsigngujarati":2755,"s":115,"sabengali":2488,"sacute":347,"sacutedotaccent":7781,"sadarabic":1589,"sadeva":2360,"sadfinalarabic":65210,"sadinitialarabic":65211,"sadmedialarabic":65212,"sagujarati":2744,"sagurmukhi":2616,"sahiragana":12373,"sakatakana":12469,"sakatakanahalfwidth":65403,"sallallahoualayhewasallamarabic":65018,"samekh":1505,"samekhdagesh":64321,"samekhdageshhebrew":64321,"samekhhebrew":1505,"saraaathai":3634,"saraaethai":3649,"saraaimaimalaithai":3652,"saraaimaimuanthai":3651,"saraamthai":3635,"saraathai":3632,"saraethai":3648,"saraiileftthai":63622,"saraiithai":3637,"saraileftthai":63621,"saraithai":3636,"saraothai":3650,"saraueeleftthai":63624,"saraueethai":3639,"saraueleftthai":63623,"sarauethai":3638,"sarauthai":3640,"sarauuthai":3641,"sbopomofo":12569,"scaron":353,"scarondotaccent":7783,"scedilla":351,"schwa":601,"schwacyrillic":1241,"schwadieresiscyrillic":1243,"schwahook":602,"scircle":9442,"scircumflex":349,"scommaaccent":537,"sdotaccent":7777,"sdotbelow":7779,"sdotbelowdotaccent":7785,"seagullbelowcmb":828,"second":8243,"secondtonechinese":714,"section":167,"seenarabic":1587,"seenfinalarabic":65202,"seeninitialarabic":65203,"seenmedialarabic":65204,"segol":1462,"segol13":1462,"segol1f":1462,"segol2c":1462,"segolhebrew":1462,"segolnarrowhebrew":1462,"segolquarterhebrew":1462,"segoltahebrew":1426,"segolwidehebrew":1462,"seharmenian":1405,"sehiragana":12379,"sekatakana":12475,"sekatakanahalfwidth":65406,"semicolon":59,"semicolonarabic":1563,"semicolonmonospace":65307,"semicolonsmall":65108,"semivoicedmarkkana":12444,"semivoicedmarkkanahalfwidth":65439,"sentisquare":13090,"sentosquare":13091,"seven":55,"sevenarabic":1639,"sevenbengali":2541,"sevencircle":9318,"sevencircleinversesansserif":10128,"sevendeva":2413,"seveneighths":8542,"sevengujarati":2797,"sevengurmukhi":2669,"sevenhackarabic":1639,"sevenhangzhou":12327,"sevenideographicparen":12838,"seveninferior":8327,"sevenmonospace":65303,"sevenoldstyle":63287,"sevenparen":9338,"sevenperiod":9358,"sevenpersian":1783,"sevenroman":8566,"sevensuperior":8311,"seventeencircle":9328,"seventeenparen":9348,"seventeenperiod":9368,"seventhai":3671,"sfthyphen":173,"shaarmenian":1399,"shabengali":2486,"shacyrillic":1096,"shaddaarabic":1617,"shaddadammaarabic":64609,"shaddadammatanarabic":64606,"shaddafathaarabic":64608,"shaddakasraarabic":64610,"shaddakasratanarabic":64607,"shade":9618,"shadedark":9619,"shadelight":9617,"shademedium":9618,"shadeva":2358,"shagujarati":2742,"shagurmukhi":2614,"shalshelethebrew":1427,"shbopomofo":12565,"shchacyrillic":1097,"sheenarabic":1588,"sheenfinalarabic":65206,"sheeninitialarabic":65207,"sheenmedialarabic":65208,"sheicoptic":995,"sheqel":8362,"sheqelhebrew":8362,"sheva":1456,"sheva115":1456,"sheva15":1456,"sheva22":1456,"sheva2e":1456,"shevahebrew":1456,"shevanarrowhebrew":1456,"shevaquarterhebrew":1456,"shevawidehebrew":1456,"shhacyrillic":1211,"shimacoptic":1005,"shin":1513,"shindagesh":64329,"shindageshhebrew":64329,"shindageshshindot":64300,"shindageshshindothebrew":64300,"shindageshsindot":64301,"shindageshsindothebrew":64301,"shindothebrew":1473,"shinhebrew":1513,"shinshindot":64298,"shinshindothebrew":64298,"shinsindot":64299,"shinsindothebrew":64299,"shook":642,"sigma":963,"sigma1":962,"sigmafinal":962,"sigmalunatesymbolgreek":1010,"sihiragana":12375,"sikatakana":12471,"sikatakanahalfwidth":65404,"siluqhebrew":1469,"siluqlefthebrew":1469,"similar":8764,"sindothebrew":1474,"siosacirclekorean":12916,"siosaparenkorean":12820,"sioscieuckorean":12670,"sioscirclekorean":12902,"sioskiyeokkorean":12666,"sioskorean":12613,"siosnieunkorean":12667,"siosparenkorean":12806,"siospieupkorean":12669,"siostikeutkorean":12668,"six":54,"sixarabic":1638,"sixbengali":2540,"sixcircle":9317,"sixcircleinversesansserif":10127,"sixdeva":2412,"sixgujarati":2796,"sixgurmukhi":2668,"sixhackarabic":1638,"sixhangzhou":12326,"sixideographicparen":12837,"sixinferior":8326,"sixmonospace":65302,"sixoldstyle":63286,"sixparen":9337,"sixperiod":9357,"sixpersian":1782,"sixroman":8565,"sixsuperior":8310,"sixteencircle":9327,"sixteencurrencydenominatorbengali":2553,"sixteenparen":9347,"sixteenperiod":9367,"sixthai":3670,"slash":47,"slashmonospace":65295,"slong":383,"slongdotaccent":7835,"smileface":9786,"smonospace":65363,"sofpasuqhebrew":1475,"softhyphen":173,"softsigncyrillic":1100,"sohiragana":12381,"sokatakana":12477,"sokatakanahalfwidth":65407,"soliduslongoverlaycmb":824,"solidusshortoverlaycmb":823,"sorusithai":3625,"sosalathai":3624,"sosothai":3595,"sosuathai":3626,"space":32,"spacehackarabic":32,"spade":9824,"spadesuitblack":9824,"spadesuitwhite":9828,"sparen":9390,"squarebelowcmb":827,"squarecc":13252,"squarecm":13213,"squarediagonalcrosshatchfill":9641,"squarehorizontalfill":9636,"squarekg":13199,"squarekm":13214,"squarekmcapital":13262,"squareln":13265,"squarelog":13266,"squaremg":13198,"squaremil":13269,"squaremm":13212,"squaremsquared":13217,"squareorthogonalcrosshatchfill":9638,"squareupperlefttolowerrightfill":9639,"squareupperrighttolowerleftfill":9640,"squareverticalfill":9637,"squarewhitewithsmallblack":9635,"srsquare":13275,"ssabengali":2487,"ssadeva":2359,"ssagujarati":2743,"ssangcieuckorean":12617,"ssanghieuhkorean":12677,"ssangieungkorean":12672,"ssangkiyeokkorean":12594,"ssangnieunkorean":12645,"ssangpieupkorean":12611,"ssangsioskorean":12614,"ssangtikeutkorean":12600,"ssuperior":63218,"sterling":163,"sterlingmonospace":65505,"strokelongoverlaycmb":822,"strokeshortoverlaycmb":821,"subset":8834,"subsetnotequal":8842,"subsetorequal":8838,"succeeds":8827,"suchthat":8715,"suhiragana":12377,"sukatakana":12473,"sukatakanahalfwidth":65405,"sukunarabic":1618,"summation":8721,"sun":9788,"superset":8835,"supersetnotequal":8843,"supersetorequal":8839,"svsquare":13276,"syouwaerasquare":13180,"t":116,"tabengali":2468,"tackdown":8868,"tackleft":8867,"tadeva":2340,"tagujarati":2724,"tagurmukhi":2596,"taharabic":1591,"tahfinalarabic":65218,"tahinitialarabic":65219,"tahiragana":12383,"tahmedialarabic":65220,"taisyouerasquare":13181,"takatakana":12479,"takatakanahalfwidth":65408,"tatweelarabic":1600,"tau":964,"tav":1514,"tavdages":64330,"tavdagesh":64330,"tavdageshhebrew":64330,"tavhebrew":1514,"tbar":359,"tbopomofo":12554,"tcaron":357,"tccurl":680,"tcedilla":355,"tcheharabic":1670,"tchehfinalarabic":64379,"tchehinitialarabic":64380,"tchehmedialarabic":64381,"tcircle":9443,"tcircumflexbelow":7793,"tcommaaccent":355,"tdieresis":7831,"tdotaccent":7787,"tdotbelow":7789,"tecyrillic":1090,"tedescendercyrillic":1197,"teharabic":1578,"tehfinalarabic":65174,"tehhahinitialarabic":64674,"tehhahisolatedarabic":64524,"tehinitialarabic":65175,"tehiragana":12390,"tehjeeminitialarabic":64673,"tehjeemisolatedarabic":64523,"tehmarbutaarabic":1577,"tehmarbutafinalarabic":65172,"tehmedialarabic":65176,"tehmeeminitialarabic":64676,"tehmeemisolatedarabic":64526,"tehnoonfinalarabic":64627,"tekatakana":12486,"tekatakanahalfwidth":65411,"telephone":8481,"telephoneblack":9742,"telishagedolahebrew":1440,"telishaqetanahebrew":1449,"tencircle":9321,"tenideographicparen":12841,"tenparen":9341,"tenperiod":9361,"tenroman":8569,"tesh":679,"tet":1496,"tetdagesh":64312,"tetdageshhebrew":64312,"tethebrew":1496,"tetsecyrillic":1205,"tevirhebrew":1435,"tevirlefthebrew":1435,"thabengali":2469,"thadeva":2341,"thagujarati":2725,"thagurmukhi":2597,"thalarabic":1584,"thalfinalarabic":65196,"thanthakhatlowleftthai":63640,"thanthakhatlowrightthai":63639,"thanthakhatthai":3660,"thanthakhatupperleftthai":63638,"theharabic":1579,"thehfinalarabic":65178,"thehinitialarabic":65179,"thehmedialarabic":65180,"thereexists":8707,"therefore":8756,"theta":952,"theta1":977,"thetasymbolgreek":977,"thieuthacirclekorean":12921,"thieuthaparenkorean":12825,"thieuthcirclekorean":12907,"thieuthkorean":12620,"thieuthparenkorean":12811,"thirteencircle":9324,"thirteenparen":9344,"thirteenperiod":9364,"thonangmonthothai":3601,"thook":429,"thophuthaothai":3602,"thorn":254,"thothahanthai":3607,"thothanthai":3600,"thothongthai":3608,"thothungthai":3606,"thousandcyrillic":1154,"thousandsseparatorarabic":1644,"thousandsseparatorpersian":1644,"three":51,"threearabic":1635,"threebengali":2537,"threecircle":9314,"threecircleinversesansserif":10124,"threedeva":2409,"threeeighths":8540,"threegujarati":2793,"threegurmukhi":2665,"threehackarabic":1635,"threehangzhou":12323,"threeideographicparen":12834,"threeinferior":8323,"threemonospace":65299,"threenumeratorbengali":2550,"threeoldstyle":63283,"threeparen":9334,"threeperiod":9354,"threepersian":1779,"threequarters":190,"threequartersemdash":63198,"threeroman":8562,"threesuperior":179,"threethai":3667,"thzsquare":13204,"tihiragana":12385,"tikatakana":12481,"tikatakanahalfwidth":65409,"tikeutacirclekorean":12912,"tikeutaparenkorean":12816,"tikeutcirclekorean":12898,"tikeutkorean":12599,"tikeutparenkorean":12802,"tilde":732,"tildebelowcmb":816,"tildecmb":771,"tildecomb":771,"tildedoublecmb":864,"tildeoperator":8764,"tildeoverlaycmb":820,"tildeverticalcmb":830,"timescircle":8855,"tipehahebrew":1430,"tipehalefthebrew":1430,"tippigurmukhi":2672,"titlocyrilliccmb":1155,"tiwnarmenian":1407,"tlinebelow":7791,"tmonospace":65364,"toarmenian":1385,"tohiragana":12392,"tokatakana":12488,"tokatakanahalfwidth":65412,"tonebarextrahighmod":741,"tonebarextralowmod":745,"tonebarhighmod":742,"tonebarlowmod":744,"tonebarmidmod":743,"tonefive":445,"tonesix":389,"tonetwo":424,"tonos":900,"tonsquare":13095,"topatakthai":3599,"tortoiseshellbracketleft":12308,"tortoiseshellbracketleftsmall":65117,"tortoiseshellbracketleftvertical":65081,"tortoiseshellbracketright":12309,"tortoiseshellbracketrightsmall":65118,"tortoiseshellbracketrightvertical":65082,"totaothai":3605,"tpalatalhook":427,"tparen":9391,"trademark":8482,"trademarksans":63722,"trademarkserif":63195,"tretroflexhook":648,"triagdn":9660,"triaglf":9668,"triagrt":9658,"triagup":9650,"ts":678,"tsadi":1510,"tsadidagesh":64326,"tsadidageshhebrew":64326,"tsadihebrew":1510,"tsecyrillic":1094,"tsere":1461,"tsere12":1461,"tsere1e":1461,"tsere2b":1461,"tserehebrew":1461,"tserenarrowhebrew":1461,"tserequarterhebrew":1461,"tserewidehebrew":1461,"tshecyrillic":1115,"tsuperior":63219,"ttabengali":2463,"ttadeva":2335,"ttagujarati":2719,"ttagurmukhi":2591,"tteharabic":1657,"ttehfinalarabic":64359,"ttehinitialarabic":64360,"ttehmedialarabic":64361,"tthabengali":2464,"tthadeva":2336,"tthagujarati":2720,"tthagurmukhi":2592,"tturned":647,"tuhiragana":12388,"tukatakana":12484,"tukatakanahalfwidth":65410,"tusmallhiragana":12387,"tusmallkatakana":12483,"tusmallkatakanahalfwidth":65391,"twelvecircle":9323,"twelveparen":9343,"twelveperiod":9363,"twelveroman":8571,"twentycircle":9331,"twentyhangzhou":21316,"twentyparen":9351,"twentyperiod":9371,"two":50,"twoarabic":1634,"twobengali":2536,"twocircle":9313,"twocircleinversesansserif":10123,"twodeva":2408,"twodotenleader":8229,"twodotleader":8229,"twodotleadervertical":65072,"twogujarati":2792,"twogurmukhi":2664,"twohackarabic":1634,"twohangzhou":12322,"twoideographicparen":12833,"twoinferior":8322,"twomonospace":65298,"twonumeratorbengali":2549,"twooldstyle":63282,"twoparen":9333,"twoperiod":9353,"twopersian":1778,"tworoman":8561,"twostroke":443,"twosuperior":178,"twothai":3666,"twothirds":8532,"u":117,"uacute":250,"ubar":649,"ubengali":2441,"ubopomofo":12584,"ubreve":365,"ucaron":468,"ucircle":9444,"ucircumflex":251,"ucircumflexbelow":7799,"ucyrillic":1091,"udattadeva":2385,"udblacute":369,"udblgrave":533,"udeva":2313,"udieresis":252,"udieresisacute":472,"udieresisbelow":7795,"udieresiscaron":474,"udieresiscyrillic":1265,"udieresisgrave":476,"udieresismacron":470,"udotbelow":7909,"ugrave":249,"ugujarati":2697,"ugurmukhi":2569,"uhiragana":12358,"uhookabove":7911,"uhorn":432,"uhornacute":7913,"uhorndotbelow":7921,"uhorngrave":7915,"uhornhookabove":7917,"uhorntilde":7919,"uhungarumlaut":369,"uhungarumlautcyrillic":1267,"uinvertedbreve":535,"ukatakana":12454,"ukatakanahalfwidth":65395,"ukcyrillic":1145,"ukorean":12636,"umacron":363,"umacroncyrillic":1263,"umacrondieresis":7803,"umatragurmukhi":2625,"umonospace":65365,"underscore":95,"underscoredbl":8215,"underscoremonospace":65343,"underscorevertical":65075,"underscorewavy":65103,"union":8746,"universal":8704,"uogonek":371,"uparen":9392,"upblock":9600,"upperdothebrew":1476,"upsilon":965,"upsilondieresis":971,"upsilondieresistonos":944,"upsilonlatin":650,"upsilontonos":973,"uptackbelowcmb":797,"uptackmod":724,"uragurmukhi":2675,"uring":367,"ushortcyrillic":1118,"usmallhiragana":12357,"usmallkatakana":12453,"usmallkatakanahalfwidth":65385,"ustraightcyrillic":1199,"ustraightstrokecyrillic":1201,"utilde":361,"utildeacute":7801,"utildebelow":7797,"uubengali":2442,"uudeva":2314,"uugujarati":2698,"uugurmukhi":2570,"uumatragurmukhi":2626,"uuvowelsignbengali":2498,"uuvowelsigndeva":2370,"uuvowelsigngujarati":2754,"uvowelsignbengali":2497,"uvowelsigndeva":2369,"uvowelsigngujarati":2753,"v":118,"vadeva":2357,"vagujarati":2741,"vagurmukhi":2613,"vakatakana":12535,"vav":1493,"vavdagesh":64309,"vavdagesh65":64309,"vavdageshhebrew":64309,"vavhebrew":1493,"vavholam":64331,"vavholamhebrew":64331,"vavvavhebrew":1520,"vavyodhebrew":1521,"vcircle":9445,"vdotbelow":7807,"vecyrillic":1074,"veharabic":1700,"vehfinalarabic":64363,"vehinitialarabic":64364,"vehmedialarabic":64365,"vekatakana":12537,"venus":9792,"verticalbar":124,"verticallineabovecmb":781,"verticallinebelowcmb":809,"verticallinelowmod":716,"verticallinemod":712,"vewarmenian":1406,"vhook":651,"vikatakana":12536,"viramabengali":2509,"viramadeva":2381,"viramagujarati":2765,"visargabengali":2435,"visargadeva":2307,"visargagujarati":2691,"vmonospace":65366,"voarmenian":1400,"voicediterationhiragana":12446,"voicediterationkatakana":12542,"voicedmarkkana":12443,"voicedmarkkanahalfwidth":65438,"vokatakana":12538,"vparen":9393,"vtilde":7805,"vturned":652,"vuhiragana":12436,"vukatakana":12532,"w":119,"wacute":7811,"waekorean":12633,"wahiragana":12431,"wakatakana":12527,"wakatakanahalfwidth":65436,"wakorean":12632,"wasmallhiragana":12430,"wasmallkatakana":12526,"wattosquare":13143,"wavedash":12316,"wavyunderscorevertical":65076,"wawarabic":1608,"wawfinalarabic":65262,"wawhamzaabovearabic":1572,"wawhamzaabovefinalarabic":65158,"wbsquare":13277,"wcircle":9446,"wcircumflex":373,"wdieresis":7813,"wdotaccent":7815,"wdotbelow":7817,"wehiragana":12433,"weierstrass":8472,"wekatakana":12529,"wekorean":12638,"weokorean":12637,"wgrave":7809,"whitebullet":9702,"whitecircle":9675,"whitecircleinverse":9689,"whitecornerbracketleft":12302,"whitecornerbracketleftvertical":65091,"whitecornerbracketright":12303,"whitecornerbracketrightvertical":65092,"whitediamond":9671,"whitediamondcontainingblacksmalldiamond":9672,"whitedownpointingsmalltriangle":9663,"whitedownpointingtriangle":9661,"whiteleftpointingsmalltriangle":9667,"whiteleftpointingtriangle":9665,"whitelenticularbracketleft":12310,"whitelenticularbracketright":12311,"whiterightpointingsmalltriangle":9657,"whiterightpointingtriangle":9655,"whitesmallsquare":9643,"whitesmilingface":9786,"whitesquare":9633,"whitestar":9734,"whitetelephone":9743,"whitetortoiseshellbracketleft":12312,"whitetortoiseshellbracketright":12313,"whiteuppointingsmalltriangle":9653,"whiteuppointingtriangle":9651,"wihiragana":12432,"wikatakana":12528,"wikorean":12639,"wmonospace":65367,"wohiragana":12434,"wokatakana":12530,"wokatakanahalfwidth":65382,"won":8361,"wonmonospace":65510,"wowaenthai":3623,"wparen":9394,"wring":7832,"wsuperior":695,"wturned":653,"wynn":447,"x":120,"xabovecmb":829,"xbopomofo":12562,"xcircle":9447,"xdieresis":7821,"xdotaccent":7819,"xeharmenian":1389,"xi":958,"xmonospace":65368,"xparen":9395,"xsuperior":739,"y":121,"yaadosquare":13134,"yabengali":2479,"yacute":253,"yadeva":2351,"yaekorean":12626,"yagujarati":2735,"yagurmukhi":2607,"yahiragana":12420,"yakatakana":12516,"yakatakanahalfwidth":65428,"yakorean":12625,"yamakkanthai":3662,"yasmallhiragana":12419,"yasmallkatakana":12515,"yasmallkatakanahalfwidth":65388,"yatcyrillic":1123,"ycircle":9448,"ycircumflex":375,"ydieresis":255,"ydotaccent":7823,"ydotbelow":7925,"yeharabic":1610,"yehbarreearabic":1746,"yehbarreefinalarabic":64431,"yehfinalarabic":65266,"yehhamzaabovearabic":1574,"yehhamzaabovefinalarabic":65162,"yehhamzaaboveinitialarabic":65163,"yehhamzaabovemedialarabic":65164,"yehinitialarabic":65267,"yehmedialarabic":65268,"yehmeeminitialarabic":64733,"yehmeemisolatedarabic":64600,"yehnoonfinalarabic":64660,"yehthreedotsbelowarabic":1745,"yekorean":12630,"yen":165,"yenmonospace":65509,"yeokorean":12629,"yeorinhieuhkorean":12678,"yerahbenyomohebrew":1450,"yerahbenyomolefthebrew":1450,"yericyrillic":1099,"yerudieresiscyrillic":1273,"yesieungkorean":12673,"yesieungpansioskorean":12675,"yesieungsioskorean":12674,"yetivhebrew":1434,"ygrave":7923,"yhook":436,"yhookabove":7927,"yiarmenian":1397,"yicyrillic":1111,"yikorean":12642,"yinyang":9775,"yiwnarmenian":1410,"ymonospace":65369,"yod":1497,"yoddagesh":64313,"yoddageshhebrew":64313,"yodhebrew":1497,"yodyodhebrew":1522,"yodyodpatahhebrew":64287,"yohiragana":12424,"yoikorean":12681,"yokatakana":12520,"yokatakanahalfwidth":65430,"yokorean":12635,"yosmallhiragana":12423,"yosmallkatakana":12519,"yosmallkatakanahalfwidth":65390,"yotgreek":1011,"yoyaekorean":12680,"yoyakorean":12679,"yoyakthai":3618,"yoyingthai":3597,"yparen":9396,"ypogegrammeni":890,"ypogegrammenigreekcmb":837,"yr":422,"yring":7833,"ysuperior":696,"ytilde":7929,"yturned":654,"yuhiragana":12422,"yuikorean":12684,"yukatakana":12518,"yukatakanahalfwidth":65429,"yukorean":12640,"yusbigcyrillic":1131,"yusbigiotifiedcyrillic":1133,"yuslittlecyrillic":1127,"yuslittleiotifiedcyrillic":1129,"yusmallhiragana":12421,"yusmallkatakana":12517,"yusmallkatakanahalfwidth":65389,"yuyekorean":12683,"yuyeokorean":12682,"yyabengali":2527,"yyadeva":2399,"z":122,"zaarmenian":1382,"zacute":378,"zadeva":2395,"zagurmukhi":2651,"zaharabic":1592,"zahfinalarabic":65222,"zahinitialarabic":65223,"zahiragana":12374,"zahmedialarabic":65224,"zainarabic":1586,"zainfinalarabic":65200,"zakatakana":12470,"zaqefgadolhebrew":1429,"zaqefqatanhebrew":1428,"zarqahebrew":1432,"zayin":1494,"zayindagesh":64310,"zayindageshhebrew":64310,"zayinhebrew":1494,"zbopomofo":12567,"zcaron":382,"zcircle":9449,"zcircumflex":7825,"zcurl":657,"zdot":380,"zdotaccent":380,"zdotbelow":7827,"zecyrillic":1079,"zedescendercyrillic":1177,"zedieresiscyrillic":1247,"zehiragana":12380,"zekatakana":12476,"zero":48,"zeroarabic":1632,"zerobengali":2534,"zerodeva":2406,"zerogujarati":2790,"zerogurmukhi":2662,"zerohackarabic":1632,"zeroinferior":8320,"zeromonospace":65296,"zerooldstyle":63280,"zeropersian":1776,"zerosuperior":8304,"zerothai":3664,"zerowidthjoiner":65279,"zerowidthnonjoiner":8204,"zerowidthspace":8203,"zeta":950,"zhbopomofo":12563,"zhearmenian":1386,"zhebrevecyrillic":1218,"zhecyrillic":1078,"zhedescendercyrillic":1175,"zhedieresiscyrillic":1245,"zihiragana":12376,"zikatakana":12472,"zinorhebrew":1454,"zlinebelow":7829,"zmonospace":65370,"zohiragana":12382,"zokatakana":12478,"zparen":9397,"zretroflexhook":656,"zstroke":438,"zuhiragana":12378,"zukatakana":12474,".notdef":0,"angbracketleftbig":9001,"angbracketleftBig":9001,"angbracketleftbigg":9001,"angbracketleftBigg":9001,"angbracketrightBig":9002,"angbracketrightbig":9002,"angbracketrightBigg":9002,"angbracketrightbigg":9002,"arrowhookleft":8618,"arrowhookright":8617,"arrowlefttophalf":8636,"arrowleftbothalf":8637,"arrownortheast":8599,"arrownorthwest":8598,"arrowrighttophalf":8640,"arrowrightbothalf":8641,"arrowsoutheast":8600,"arrowsouthwest":8601,"backslashbig":8726,"backslashBig":8726,"backslashBigg":8726,"backslashbigg":8726,"bardbl":8214,"bracehtipdownleft":65079,"bracehtipdownright":65079,"bracehtipupleft":65080,"bracehtipupright":65080,"braceleftBig":123,"braceleftbig":123,"braceleftbigg":123,"braceleftBigg":123,"bracerightBig":125,"bracerightbig":125,"bracerightbigg":125,"bracerightBigg":125,"bracketleftbig":91,"bracketleftBig":91,"bracketleftbigg":91,"bracketleftBigg":91,"bracketrightBig":93,"bracketrightbig":93,"bracketrightbigg":93,"bracketrightBigg":93,"ceilingleftbig":8968,"ceilingleftBig":8968,"ceilingleftBigg":8968,"ceilingleftbigg":8968,"ceilingrightbig":8969,"ceilingrightBig":8969,"ceilingrightbigg":8969,"ceilingrightBigg":8969,"circledotdisplay":8857,"circledottext":8857,"circlemultiplydisplay":8855,"circlemultiplytext":8855,"circleplusdisplay":8853,"circleplustext":8853,"contintegraldisplay":8750,"contintegraltext":8750,"coproductdisplay":8720,"coproducttext":8720,"floorleftBig":8970,"floorleftbig":8970,"floorleftbigg":8970,"floorleftBigg":8970,"floorrightbig":8971,"floorrightBig":8971,"floorrightBigg":8971,"floorrightbigg":8971,"hatwide":770,"hatwider":770,"hatwidest":770,"intercal":7488,"integraldisplay":8747,"integraltext":8747,"intersectiondisplay":8898,"intersectiontext":8898,"logicalanddisplay":8743,"logicalandtext":8743,"logicalordisplay":8744,"logicalortext":8744,"parenleftBig":40,"parenleftbig":40,"parenleftBigg":40,"parenleftbigg":40,"parenrightBig":41,"parenrightbig":41,"parenrightBigg":41,"parenrightbigg":41,"prime":8242,"productdisplay":8719,"producttext":8719,"radicalbig":8730,"radicalBig":8730,"radicalBigg":8730,"radicalbigg":8730,"radicalbt":8730,"radicaltp":8730,"radicalvertex":8730,"slashbig":47,"slashBig":47,"slashBigg":47,"slashbigg":47,"summationdisplay":8721,"summationtext":8721,"tildewide":732,"tildewider":732,"tildewidest":732,"uniondisplay":8899,"unionmultidisplay":8846,"unionmultitext":8846,"unionsqdisplay":8852,"unionsqtext":8852,"uniontext":8899,"vextenddouble":8741,"vextendsingle":8739},"encodings":{"ExpertEncoding":["","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","space","exclamsmall","Hungarumlautsmall","","dollaroldstyle","dollarsuperior","ampersandsmall","Acutesmall","parenleftsuperior","parenrightsuperior","twodotenleader","onedotenleader","comma","hyphen","period","fraction","zerooldstyle","oneoldstyle","twooldstyle","threeoldstyle","fouroldstyle","fiveoldstyle","sixoldstyle","sevenoldstyle","eightoldstyle","nineoldstyle","colon","semicolon","commasuperior","threequartersemdash","periodsuperior","questionsmall","","asuperior","bsuperior","centsuperior","dsuperior","esuperior","","","","isuperior","","","lsuperior","msuperior","nsuperior","osuperior","","","rsuperior","ssuperior","tsuperior","","ff","fi","fl","ffi","ffl","parenleftinferior","","parenrightinferior","Circumflexsmall","hyphensuperior","Gravesmall","Asmall","Bsmall","Csmall","Dsmall","Esmall","Fsmall","Gsmall","Hsmall","Ismall","Jsmall","Ksmall","Lsmall","Msmall","Nsmall","Osmall","Psmall","Qsmall","Rsmall","Ssmall","Tsmall","Usmall","Vsmall","Wsmall","Xsmall","Ysmall","Zsmall","colonmonetary","onefitted","rupiah","Tildesmall","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","exclamdownsmall","centoldstyle","Lslashsmall","","","Scaronsmall","Zcaronsmall","Dieresissmall","Brevesmall","Caronsmall","","Dotaccentsmall","","","Macronsmall","","","figuredash","hypheninferior","","","Ogoneksmall","Ringsmall","Cedillasmall","","","","onequarter","onehalf","threequarters","questiondownsmall","oneeighth","threeeighths","fiveeighths","seveneighths","onethird","twothirds","","","zerosuperior","onesuperior","twosuperior","threesuperior","foursuperior","fivesuperior","sixsuperior","sevensuperior","eightsuperior","ninesuperior","zeroinferior","oneinferior","twoinferior","threeinferior","fourinferior","fiveinferior","sixinferior","seveninferior","eightinferior","nineinferior","centinferior","dollarinferior","periodinferior","commainferior","Agravesmall","Aacutesmall","Acircumflexsmall","Atildesmall","Adieresissmall","Aringsmall","AEsmall","Ccedillasmall","Egravesmall","Eacutesmall","Ecircumflexsmall","Edieresissmall","Igravesmall","Iacutesmall","Icircumflexsmall","Idieresissmall","Ethsmall","Ntildesmall","Ogravesmall","Oacutesmall","Ocircumflexsmall","Otildesmall","Odieresissmall","OEsmall","Oslashsmall","Ugravesmall","Uacutesmall","Ucircumflexsmall","Udieresissmall","Yacutesmall","Thornsmall","Ydieresissmall"],"MacExpertEncoding":["","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","space","exclamsmall","Hungarumlautsmall","centoldstyle","dollaroldstyle","dollarsuperior","ampersandsmall","Acutesmall","parenleftsuperior","parenrightsuperior","twodotenleader","onedotenleader","comma","hyphen","period","fraction","zerooldstyle","oneoldstyle","twooldstyle","threeoldstyle","fouroldstyle","fiveoldstyle","sixoldstyle","sevenoldstyle","eightoldstyle","nineoldstyle","colon","semicolon","","threequartersemdash","","questionsmall","","","","","Ethsmall","","","onequarter","onehalf","threequarters","oneeighth","threeeighths","fiveeighths","seveneighths","onethird","twothirds","","","","","","","ff","fi","fl","ffi","ffl","parenleftinferior","","parenrightinferior","Circumflexsmall","hypheninferior","Gravesmall","Asmall","Bsmall","Csmall","Dsmall","Esmall","Fsmall","Gsmall","Hsmall","Ismall","Jsmall","Ksmall","Lsmall","Msmall","Nsmall","Osmall","Psmall","Qsmall","Rsmall","Ssmall","Tsmall","Usmall","Vsmall","Wsmall","Xsmall","Ysmall","Zsmall","colonmonetary","onefitted","rupiah","Tildesmall","","","asuperior","centsuperior","","","","","Aacutesmall","Agravesmall","Acircumflexsmall","Adieresissmall","Atildesmall","Aringsmall","Ccedillasmall","Eacutesmall","Egravesmall","Ecircumflexsmall","Edieresissmall","Iacutesmall","Igravesmall","Icircumflexsmall","Idieresissmall","Ntildesmall","Oacutesmall","Ogravesmall","Ocircumflexsmall","Odieresissmall","Otildesmall","Uacutesmall","Ugravesmall","Ucircumflexsmall","Udieresissmall","","eightsuperior","fourinferior","threeinferior","sixinferior","eightinferior","seveninferior","Scaronsmall","","centinferior","twoinferior","","Dieresissmall","","Caronsmall","osuperior","fiveinferior","","commainferior","periodinferior","Yacutesmall","","dollarinferior","","","Thornsmall","","nineinferior","zeroinferior","Zcaronsmall","AEsmall","Oslashsmall","questiondownsmall","oneinferior","Lslashsmall","","","","","","","Cedillasmall","","","","","","OEsmall","figuredash","hyphensuperior","","","","","exclamdownsmall","","Ydieresissmall","","onesuperior","twosuperior","threesuperior","foursuperior","fivesuperior","sixsuperior","sevensuperior","ninesuperior","zerosuperior","","esuperior","rsuperior","tsuperior","","","isuperior","ssuperior","dsuperior","","","","","","lsuperior","Ogoneksmall","Brevesmall","Macronsmall","bsuperior","nsuperior","msuperior","commasuperior","periodsuperior","Dotaccentsmall","Ringsmall","","","",""],"MacRomanEncoding":["","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","space","exclam","quotedbl","numbersign","dollar","percent","ampersand","quotesingle","parenleft","parenright","asterisk","plus","comma","hyphen","period","slash","zero","one","two","three","four","five","six","seven","eight","nine","colon","semicolon","less","equal","greater","question","at","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z","bracketleft","backslash","bracketright","asciicircum","underscore","grave","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","braceleft","bar","braceright","asciitilde","","Adieresis","Aring","Ccedilla","Eacute","Ntilde","Odieresis","Udieresis","aacute","agrave","acircumflex","adieresis","atilde","aring","ccedilla","eacute","egrave","ecircumflex","edieresis","iacute","igrave","icircumflex","idieresis","ntilde","oacute","ograve","ocircumflex","odieresis","otilde","uacute","ugrave","ucircumflex","udieresis","dagger","degree","cent","sterling","section","bullet","paragraph","germandbls","registered","copyright","trademark","acute","dieresis","notequal","AE","Oslash","infinity","plusminus","lessequal","greaterequal","yen","mu","partialdiff","summation","product","pi","integral","ordfeminine","ordmasculine","Omega","ae","oslash","questiondown","exclamdown","logicalnot","radical","florin","approxequal","Delta","guillemotleft","guillemotright","ellipsis","space","Agrave","Atilde","Otilde","OE","oe","endash","emdash","quotedblleft","quotedblright","quoteleft","quoteright","divide","lozenge","ydieresis","Ydieresis","fraction","currency","guilsinglleft","guilsinglright","fi","fl","daggerdbl","periodcentered","quotesinglbase","quotedblbase","perthousand","Acircumflex","Ecircumflex","Aacute","Edieresis","Egrave","Iacute","Icircumflex","Idieresis","Igrave","Oacute","Ocircumflex","apple","Ograve","Uacute","Ucircumflex","Ugrave","dotlessi","circumflex","tilde","macron","breve","dotaccent","ring","cedilla","hungarumlaut","ogonek","caron"],"StandardEncoding":["","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","space","exclam","quotedbl","numbersign","dollar","percent","ampersand","quoteright","parenleft","parenright","asterisk","plus","comma","hyphen","period","slash","zero","one","two","three","four","five","six","seven","eight","nine","colon","semicolon","less","equal","greater","question","at","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z","bracketleft","backslash","bracketright","asciicircum","underscore","quoteleft","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","braceleft","bar","braceright","asciitilde","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","exclamdown","cent","sterling","fraction","yen","florin","section","currency","quotesingle","quotedblleft","guillemotleft","guilsinglleft","guilsinglright","fi","fl","","endash","dagger","daggerdbl","periodcentered","","paragraph","bullet","quotesinglbase","quotedblbase","quotedblright","guillemotright","ellipsis","perthousand","","questiondown","","grave","acute","circumflex","tilde","macron","breve","dotaccent","dieresis","","ring","cedilla","","hungarumlaut","ogonek","caron","emdash","","","","","","","","","","","","","","","","","AE","","ordfeminine","","","","","Lslash","Oslash","OE","ordmasculine","","","","","","ae","","","","dotlessi","","","lslash","oslash","oe","germandbls","","","",""],"WinAnsiEncoding":["","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","space","exclam","quotedbl","numbersign","dollar","percent","ampersand","quotesingle","parenleft","parenright","asterisk","plus","comma","hyphen","period","slash","zero","one","two","three","four","five","six","seven","eight","nine","colon","semicolon","less","equal","greater","question","at","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z","bracketleft","backslash","bracketright","asciicircum","underscore","grave","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","braceleft","bar","braceright","asciitilde","bullet","Euro","bullet","quotesinglbase","florin","quotedblbase","ellipsis","dagger","daggerdbl","circumflex","perthousand","Scaron","guilsinglleft","OE","bullet","Zcaron","bullet","bullet","quoteleft","quoteright","quotedblleft","quotedblright","bullet","endash","emdash","tilde","trademark","scaron","guilsinglright","oe","bullet","zcaron","Ydieresis","space","exclamdown","cent","sterling","currency","yen","brokenbar","section","dieresis","copyright","ordfeminine","guillemotleft","logicalnot","hyphen","registered","macron","degree","plusminus","twosuperior","threesuperior","acute","mu","paragraph","periodcentered","cedilla","onesuperior","ordmasculine","guillemotright","onequarter","onehalf","threequarters","questiondown","Agrave","Aacute","Acircumflex","Atilde","Adieresis","Aring","AE","Ccedilla","Egrave","Eacute","Ecircumflex","Edieresis","Igrave","Iacute","Icircumflex","Idieresis","Eth","Ntilde","Ograve","Oacute","Ocircumflex","Otilde","Odieresis","multiply","Oslash","Ugrave","Uacute","Ucircumflex","Udieresis","Yacute","Thorn","germandbls","agrave","aacute","acircumflex","atilde","adieresis","aring","ae","ccedilla","egrave","eacute","ecircumflex","edieresis","igrave","iacute","icircumflex","idieresis","eth","ntilde","ograve","oacute","ocircumflex","otilde","odieresis","divide","oslash","ugrave","uacute","ucircumflex","udieresis","yacute","thorn","ydieresis"],"SymbolSetEncoding":["","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","space","exclam","universal","numbersign","existential","percent","ampersand","suchthat","parenleft","parenright","asteriskmath","plus","comma","minus","period","slash","zero","one","two","three","four","five","six","seven","eight","nine","colon","semicolon","less","equal","greater","question","congruent","Alpha","Beta","Chi","Delta","Epsilon","Phi","Gamma","Eta","Iota","theta1","Kappa","Lambda","Mu","Nu","Omicron","Pi","Theta","Rho","Sigma","Tau","Upsilon","sigma1","Omega","Xi","Psi","Zeta","bracketleft","therefore","bracketright","perpendicular","underscore","radicalex","alpha","beta","chi","delta","epsilon","phi","gamma","eta","iota","phi1","kappa","lambda","mu","nu","omicron","pi","theta","rho","sigma","tau","upsilon","omega1","omega","xi","psi","zeta","braceleft","bar","braceright","similar","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","Euro","Upsilon1","minute","lessequal","fraction","infinity","florin","club","diamond","heart","spade","arrowboth","arrowleft","arrowup","arrowright","arrowdown","degree","plusminus","second","greaterequal","multiply","proportional","partialdiff","bullet","divide","notequal","equivalence","approxequal","ellipsis","arrowvertex","arrowhorizex","carriagereturn","aleph","Ifraktur","Rfraktur","weierstrass","circlemultiply","circleplus","emptyset","intersection","union","propersuperset","reflexsuperset","notsubset","propersubset","reflexsubset","element","notelement","angle","gradient","registerserif","copyrightserif","trademarkserif","product","radical","dotmath","logicalnot","logicaland","logicalor","arrowdblboth","arrowdblleft","arrowdblup","arrowdblright","arrowdbldown","lozenge","angleleft","registersans","copyrightsans","trademarksans","summation","parenlefttp","parenleftex","parenleftbt","bracketlefttp","bracketleftex","bracketleftbt","bracelefttp","braceleftmid","braceleftbt","braceex","","angleright","integral","integraltp","integralex","integralbt","parenrighttp","parenrightex","parenrightbt","bracketrighttp","bracketrightex","bracketrightbt","bracerighttp","bracerightmid","bracerightbt",""],"ZapfDingbatsEncoding":["","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","space","a1","a2","a202","a3","a4","a5","a119","a118","a117","a11","a12","a13","a14","a15","a16","a105","a17","a18","a19","a20","a21","a22","a23","a24","a25","a26","a27","a28","a6","a7","a8","a9","a10","a29","a30","a31","a32","a33","a34","a35","a36","a37","a38","a39","a40","a41","a42","a43","a44","a45","a46","a47","a48","a49","a50","a51","a52","a53","a54","a55","a56","a57","a58","a59","a60","a61","a62","a63","a64","a65","a66","a67","a68","a69","a70","a71","a72","a73","a74","a203","a75","a204","a76","a77","a78","a79","a81","a82","a83","a84","a97","a98","a99","a100","","a89","a90","a93","a94","a91","a92","a205","a85","a206","a86","a87","a88","a95","a96","","","","","","","","","","","","","","","","","","","","a101","a102","a103","a104","a106","a107","a108","a112","a111","a110","a109","a120","a121","a122","a123","a124","a125","a126","a127","a128","a129","a130","a131","a132","a133","a134","a135","a136","a137","a138","a139","a140","a141","a142","a143","a144","a145","a146","a147","a148","a149","a150","a151","a152","a153","a154","a155","a156","a157","a158","a159","a160","a161","a163","a164","a196","a165","a192","a166","a167","a168","a169","a170","a171","a172","a173","a162","a174","a175","a176","a177","a178","a179","a193","a180","a199","a181","a200","a182","","a201","a183","a184","a197","a185","a194","a198","a186","a195","a187","a188","a189","a190","a191",""]}} \ No newline at end of file diff --git a/pageindex/flash/data/normalized_unicodes.json b/pageindex/flash/data/normalized_unicodes.json new file mode 100644 index 000000000..19c28ee60 --- /dev/null +++ b/pageindex/flash/data/normalized_unicodes.json @@ -0,0 +1 @@ +{"¨":" ̈","¯":" ̄","´":" ́","µ":"μ","¸":" ̧","IJ":"IJ","ij":"ij","Ŀ":"L·","ŀ":"l·","ʼn":"ʼn","ſ":"s","DŽ":"DŽ","Dž":"Dž","dž":"dž","LJ":"LJ","Lj":"Lj","lj":"lj","NJ":"NJ","Nj":"Nj","nj":"nj","DZ":"DZ","Dz":"Dz","dz":"dz","˘":" ̆","˙":" ̇","˚":" ̊","˛":" ̨","˜":" ̃","˝":" ̋","ͺ":" ͅ","΄":" ́","ϐ":"β","ϑ":"θ","ϒ":"Υ","ϕ":"φ","ϖ":"π","ϰ":"κ","ϱ":"ρ","ϲ":"ς","ϴ":"Θ","ϵ":"ε","Ϲ":"Σ","և":"եւ","ٵ":"اٴ","ٶ":"وٴ","ٷ":"ۇٴ","ٸ":"يٴ","ำ":"ํา","ຳ":"ໍາ","ໜ":"ຫນ","ໝ":"ຫມ","ཷ":"ྲཱྀ","ཹ":"ླཱྀ","ẚ":"aʾ","᾽":" ̓","᾿":" ̓","῀":" ͂","῾":" ̔"," ":" "," ":" "," ":" "," ":" "," ":" "," ":" "," ":" "," ":" ","‗":" ̳","․":".","‥":"..","…":"...","″":"′′","‴":"′′′","‶":"‵‵","‷":"‵‵‵","‼":"!!","‾":" ̅","⁇":"??","⁈":"?!","⁉":"!?","⁗":"′′′′"," ":" ","₨":"Rs","℀":"a/c","℁":"a/s","℃":"°C","℅":"c/o","℆":"c/u","ℇ":"Ɛ","℉":"°F","№":"No","℡":"TEL","ℵ":"א","ℶ":"ב","ℷ":"ג","ℸ":"ד","℻":"FAX","Ⅰ":"I","Ⅱ":"II","Ⅲ":"III","Ⅳ":"IV","Ⅴ":"V","Ⅵ":"VI","Ⅶ":"VII","Ⅷ":"VIII","Ⅸ":"IX","Ⅹ":"X","Ⅺ":"XI","Ⅻ":"XII","Ⅼ":"L","Ⅽ":"C","Ⅾ":"D","Ⅿ":"M","ⅰ":"i","ⅱ":"ii","ⅲ":"iii","ⅳ":"iv","ⅴ":"v","ⅵ":"vi","ⅶ":"vii","ⅷ":"viii","ⅸ":"ix","ⅹ":"x","ⅺ":"xi","ⅻ":"xii","ⅼ":"l","ⅽ":"c","ⅾ":"d","ⅿ":"m","∬":"∫∫","∭":"∫∫∫","∯":"∮∮","∰":"∮∮∮","⑴":"(1)","⑵":"(2)","⑶":"(3)","⑷":"(4)","⑸":"(5)","⑹":"(6)","⑺":"(7)","⑻":"(8)","⑼":"(9)","⑽":"(10)","⑾":"(11)","⑿":"(12)","⒀":"(13)","⒁":"(14)","⒂":"(15)","⒃":"(16)","⒄":"(17)","⒅":"(18)","⒆":"(19)","⒇":"(20)","⒈":"1.","⒉":"2.","⒊":"3.","⒋":"4.","⒌":"5.","⒍":"6.","⒎":"7.","⒏":"8.","⒐":"9.","⒑":"10.","⒒":"11.","⒓":"12.","⒔":"13.","⒕":"14.","⒖":"15.","⒗":"16.","⒘":"17.","⒙":"18.","⒚":"19.","⒛":"20.","⒜":"(a)","⒝":"(b)","⒞":"(c)","⒟":"(d)","⒠":"(e)","⒡":"(f)","⒢":"(g)","⒣":"(h)","⒤":"(i)","⒥":"(j)","⒦":"(k)","⒧":"(l)","⒨":"(m)","⒩":"(n)","⒪":"(o)","⒫":"(p)","⒬":"(q)","⒭":"(r)","⒮":"(s)","⒯":"(t)","⒰":"(u)","⒱":"(v)","⒲":"(w)","⒳":"(x)","⒴":"(y)","⒵":"(z)","⨌":"∫∫∫∫","⩴":"::=","⩵":"==","⩶":"===","⺟":"母","⻳":"龟","⼀":"一","⼁":"丨","⼂":"丶","⼃":"丿","⼄":"乙","⼅":"亅","⼆":"二","⼇":"亠","⼈":"人","⼉":"儿","⼊":"入","⼋":"八","⼌":"冂","⼍":"冖","⼎":"冫","⼏":"几","⼐":"凵","⼑":"刀","⼒":"力","⼓":"勹","⼔":"匕","⼕":"匚","⼖":"匸","⼗":"十","⼘":"卜","⼙":"卩","⼚":"厂","⼛":"厶","⼜":"又","⼝":"口","⼞":"囗","⼟":"土","⼠":"士","⼡":"夂","⼢":"夊","⼣":"夕","⼤":"大","⼥":"女","⼦":"子","⼧":"宀","⼨":"寸","⼩":"小","⼪":"尢","⼫":"尸","⼬":"屮","⼭":"山","⼮":"巛","⼯":"工","⼰":"己","⼱":"巾","⼲":"干","⼳":"幺","⼴":"广","⼵":"廴","⼶":"廾","⼷":"弋","⼸":"弓","⼹":"彐","⼺":"彡","⼻":"彳","⼼":"心","⼽":"戈","⼾":"戶","⼿":"手","⽀":"支","⽁":"攴","⽂":"文","⽃":"斗","⽄":"斤","⽅":"方","⽆":"无","⽇":"日","⽈":"曰","⽉":"月","⽊":"木","⽋":"欠","⽌":"止","⽍":"歹","⽎":"殳","⽏":"毋","⽐":"比","⽑":"毛","⽒":"氏","⽓":"气","⽔":"水","⽕":"火","⽖":"爪","⽗":"父","⽘":"爻","⽙":"爿","⽚":"片","⽛":"牙","⽜":"牛","⽝":"犬","⽞":"玄","⽟":"玉","⽠":"瓜","⽡":"瓦","⽢":"甘","⽣":"生","⽤":"用","⽥":"田","⽦":"疋","⽧":"疒","⽨":"癶","⽩":"白","⽪":"皮","⽫":"皿","⽬":"目","⽭":"矛","⽮":"矢","⽯":"石","⽰":"示","⽱":"禸","⽲":"禾","⽳":"穴","⽴":"立","⽵":"竹","⽶":"米","⽷":"糸","⽸":"缶","⽹":"网","⽺":"羊","⽻":"羽","⽼":"老","⽽":"而","⽾":"耒","⽿":"耳","⾀":"聿","⾁":"肉","⾂":"臣","⾃":"自","⾄":"至","⾅":"臼","⾆":"舌","⾇":"舛","⾈":"舟","⾉":"艮","⾊":"色","⾋":"艸","⾌":"虍","⾍":"虫","⾎":"血","⾏":"行","⾐":"衣","⾑":"襾","⾒":"見","⾓":"角","⾔":"言","⾕":"谷","⾖":"豆","⾗":"豕","⾘":"豸","⾙":"貝","⾚":"赤","⾛":"走","⾜":"足","⾝":"身","⾞":"車","⾟":"辛","⾠":"辰","⾡":"辵","⾢":"邑","⾣":"酉","⾤":"釆","⾥":"里","⾦":"金","⾧":"長","⾨":"門","⾩":"阜","⾪":"隶","⾫":"隹","⾬":"雨","⾭":"靑","⾮":"非","⾯":"面","⾰":"革","⾱":"韋","⾲":"韭","⾳":"音","⾴":"頁","⾵":"風","⾶":"飛","⾷":"食","⾸":"首","⾹":"香","⾺":"馬","⾻":"骨","⾼":"高","⾽":"髟","⾾":"鬥","⾿":"鬯","⿀":"鬲","⿁":"鬼","⿂":"魚","⿃":"鳥","⿄":"鹵","⿅":"鹿","⿆":"麥","⿇":"麻","⿈":"黃","⿉":"黍","⿊":"黑","⿋":"黹","⿌":"黽","⿍":"鼎","⿎":"鼓","⿏":"鼠","⿐":"鼻","⿑":"齊","⿒":"齒","⿓":"龍","⿔":"龜","⿕":"龠","〶":"〒","〸":"十","〹":"卄","〺":"卅","゛":" ゙","゜":" ゚","ㄱ":"ᄀ","ㄲ":"ᄁ","ㄳ":"ᆪ","ㄴ":"ᄂ","ㄵ":"ᆬ","ㄶ":"ᆭ","ㄷ":"ᄃ","ㄸ":"ᄄ","ㄹ":"ᄅ","ㄺ":"ᆰ","ㄻ":"ᆱ","ㄼ":"ᆲ","ㄽ":"ᆳ","ㄾ":"ᆴ","ㄿ":"ᆵ","ㅀ":"ᄚ","ㅁ":"ᄆ","ㅂ":"ᄇ","ㅃ":"ᄈ","ㅄ":"ᄡ","ㅅ":"ᄉ","ㅆ":"ᄊ","ㅇ":"ᄋ","ㅈ":"ᄌ","ㅉ":"ᄍ","ㅊ":"ᄎ","ㅋ":"ᄏ","ㅌ":"ᄐ","ㅍ":"ᄑ","ㅎ":"ᄒ","ㅏ":"ᅡ","ㅐ":"ᅢ","ㅑ":"ᅣ","ㅒ":"ᅤ","ㅓ":"ᅥ","ㅔ":"ᅦ","ㅕ":"ᅧ","ㅖ":"ᅨ","ㅗ":"ᅩ","ㅘ":"ᅪ","ㅙ":"ᅫ","ㅚ":"ᅬ","ㅛ":"ᅭ","ㅜ":"ᅮ","ㅝ":"ᅯ","ㅞ":"ᅰ","ㅟ":"ᅱ","ㅠ":"ᅲ","ㅡ":"ᅳ","ㅢ":"ᅴ","ㅣ":"ᅵ","ㅤ":"ᅠ","ㅥ":"ᄔ","ㅦ":"ᄕ","ㅧ":"ᇇ","ㅨ":"ᇈ","ㅩ":"ᇌ","ㅪ":"ᇎ","ㅫ":"ᇓ","ㅬ":"ᇗ","ㅭ":"ᇙ","ㅮ":"ᄜ","ㅯ":"ᇝ","ㅰ":"ᇟ","ㅱ":"ᄝ","ㅲ":"ᄞ","ㅳ":"ᄠ","ㅴ":"ᄢ","ㅵ":"ᄣ","ㅶ":"ᄧ","ㅷ":"ᄩ","ㅸ":"ᄫ","ㅹ":"ᄬ","ㅺ":"ᄭ","ㅻ":"ᄮ","ㅼ":"ᄯ","ㅽ":"ᄲ","ㅾ":"ᄶ","ㅿ":"ᅀ","ㆀ":"ᅇ","ㆁ":"ᅌ","ㆂ":"ᇱ","ㆃ":"ᇲ","ㆄ":"ᅗ","ㆅ":"ᅘ","ㆆ":"ᅙ","ㆇ":"ᆄ","ㆈ":"ᆅ","ㆉ":"ᆈ","ㆊ":"ᆑ","ㆋ":"ᆒ","ㆌ":"ᆔ","ㆍ":"ᆞ","ㆎ":"ᆡ","㈀":"(ᄀ)","㈁":"(ᄂ)","㈂":"(ᄃ)","㈃":"(ᄅ)","㈄":"(ᄆ)","㈅":"(ᄇ)","㈆":"(ᄉ)","㈇":"(ᄋ)","㈈":"(ᄌ)","㈉":"(ᄎ)","㈊":"(ᄏ)","㈋":"(ᄐ)","㈌":"(ᄑ)","㈍":"(ᄒ)","㈎":"(가)","㈏":"(나)","㈐":"(다)","㈑":"(라)","㈒":"(마)","㈓":"(바)","㈔":"(사)","㈕":"(아)","㈖":"(자)","㈗":"(차)","㈘":"(카)","㈙":"(타)","㈚":"(파)","㈛":"(하)","㈜":"(주)","㈝":"(오전)","㈞":"(오후)","㈠":"(一)","㈡":"(二)","㈢":"(三)","㈣":"(四)","㈤":"(五)","㈥":"(六)","㈦":"(七)","㈧":"(八)","㈨":"(九)","㈩":"(十)","㈪":"(月)","㈫":"(火)","㈬":"(水)","㈭":"(木)","㈮":"(金)","㈯":"(土)","㈰":"(日)","㈱":"(株)","㈲":"(有)","㈳":"(社)","㈴":"(名)","㈵":"(特)","㈶":"(財)","㈷":"(祝)","㈸":"(労)","㈹":"(代)","㈺":"(呼)","㈻":"(学)","㈼":"(監)","㈽":"(企)","㈾":"(資)","㈿":"(協)","㉀":"(祭)","㉁":"(休)","㉂":"(自)","㉃":"(至)","㋀":"1月","㋁":"2月","㋂":"3月","㋃":"4月","㋄":"5月","㋅":"6月","㋆":"7月","㋇":"8月","㋈":"9月","㋉":"10月","㋊":"11月","㋋":"12月","㍘":"0点","㍙":"1点","㍚":"2点","㍛":"3点","㍜":"4点","㍝":"5点","㍞":"6点","㍟":"7点","㍠":"8点","㍡":"9点","㍢":"10点","㍣":"11点","㍤":"12点","㍥":"13点","㍦":"14点","㍧":"15点","㍨":"16点","㍩":"17点","㍪":"18点","㍫":"19点","㍬":"20点","㍭":"21点","㍮":"22点","㍯":"23点","㍰":"24点","㏠":"1日","㏡":"2日","㏢":"3日","㏣":"4日","㏤":"5日","㏥":"6日","㏦":"7日","㏧":"8日","㏨":"9日","㏩":"10日","㏪":"11日","㏫":"12日","㏬":"13日","㏭":"14日","㏮":"15日","㏯":"16日","㏰":"17日","㏱":"18日","㏲":"19日","㏳":"20日","㏴":"21日","㏵":"22日","㏶":"23日","㏷":"24日","㏸":"25日","㏹":"26日","㏺":"27日","㏻":"28日","㏼":"29日","㏽":"30日","㏾":"31日","ff":"ff","fi":"fi","fl":"fl","ffi":"ffi","ffl":"ffl","ſt":"ſt","st":"st","ﬓ":"մն","ﬔ":"մե","ﬕ":"մի","ﬖ":"վն","ﬗ":"մխ","ﭏ":"אל","ﭐ":"ٱ","ﭑ":"ٱ","ﭒ":"ٻ","ﭓ":"ٻ","ﭔ":"ٻ","ﭕ":"ٻ","ﭖ":"پ","ﭗ":"پ","ﭘ":"پ","ﭙ":"پ","ﭚ":"ڀ","ﭛ":"ڀ","ﭜ":"ڀ","ﭝ":"ڀ","ﭞ":"ٺ","ﭟ":"ٺ","ﭠ":"ٺ","ﭡ":"ٺ","ﭢ":"ٿ","ﭣ":"ٿ","ﭤ":"ٿ","ﭥ":"ٿ","ﭦ":"ٹ","ﭧ":"ٹ","ﭨ":"ٹ","ﭩ":"ٹ","ﭪ":"ڤ","ﭫ":"ڤ","ﭬ":"ڤ","ﭭ":"ڤ","ﭮ":"ڦ","ﭯ":"ڦ","ﭰ":"ڦ","ﭱ":"ڦ","ﭲ":"ڄ","ﭳ":"ڄ","ﭴ":"ڄ","ﭵ":"ڄ","ﭶ":"ڃ","ﭷ":"ڃ","ﭸ":"ڃ","ﭹ":"ڃ","ﭺ":"چ","ﭻ":"چ","ﭼ":"چ","ﭽ":"چ","ﭾ":"ڇ","ﭿ":"ڇ","ﮀ":"ڇ","ﮁ":"ڇ","ﮂ":"ڍ","ﮃ":"ڍ","ﮄ":"ڌ","ﮅ":"ڌ","ﮆ":"ڎ","ﮇ":"ڎ","ﮈ":"ڈ","ﮉ":"ڈ","ﮊ":"ژ","ﮋ":"ژ","ﮌ":"ڑ","ﮍ":"ڑ","ﮎ":"ک","ﮏ":"ک","ﮐ":"ک","ﮑ":"ک","ﮒ":"گ","ﮓ":"گ","ﮔ":"گ","ﮕ":"گ","ﮖ":"ڳ","ﮗ":"ڳ","ﮘ":"ڳ","ﮙ":"ڳ","ﮚ":"ڱ","ﮛ":"ڱ","ﮜ":"ڱ","ﮝ":"ڱ","ﮞ":"ں","ﮟ":"ں","ﮠ":"ڻ","ﮡ":"ڻ","ﮢ":"ڻ","ﮣ":"ڻ","ﮤ":"ۀ","ﮥ":"ۀ","ﮦ":"ہ","ﮧ":"ہ","ﮨ":"ہ","ﮩ":"ہ","ﮪ":"ھ","ﮫ":"ھ","ﮬ":"ھ","ﮭ":"ھ","ﮮ":"ے","ﮯ":"ے","ﮰ":"ۓ","ﮱ":"ۓ","ﯓ":"ڭ","ﯔ":"ڭ","ﯕ":"ڭ","ﯖ":"ڭ","ﯗ":"ۇ","ﯘ":"ۇ","ﯙ":"ۆ","ﯚ":"ۆ","ﯛ":"ۈ","ﯜ":"ۈ","ﯝ":"ٷ","ﯞ":"ۋ","ﯟ":"ۋ","ﯠ":"ۅ","ﯡ":"ۅ","ﯢ":"ۉ","ﯣ":"ۉ","ﯤ":"ې","ﯥ":"ې","ﯦ":"ې","ﯧ":"ې","ﯨ":"ى","ﯩ":"ى","ﯪ":"ئا","ﯫ":"ئا","ﯬ":"ئە","ﯭ":"ئە","ﯮ":"ئو","ﯯ":"ئو","ﯰ":"ئۇ","ﯱ":"ئۇ","ﯲ":"ئۆ","ﯳ":"ئۆ","ﯴ":"ئۈ","ﯵ":"ئۈ","ﯶ":"ئې","ﯷ":"ئې","ﯸ":"ئې","ﯹ":"ئى","ﯺ":"ئى","ﯻ":"ئى","ﯼ":"ی","ﯽ":"ی","ﯾ":"ی","ﯿ":"ی","ﰀ":"ئج","ﰁ":"ئح","ﰂ":"ئم","ﰃ":"ئى","ﰄ":"ئي","ﰅ":"بج","ﰆ":"بح","ﰇ":"بخ","ﰈ":"بم","ﰉ":"بى","ﰊ":"بي","ﰋ":"تج","ﰌ":"تح","ﰍ":"تخ","ﰎ":"تم","ﰏ":"تى","ﰐ":"تي","ﰑ":"ثج","ﰒ":"ثم","ﰓ":"ثى","ﰔ":"ثي","ﰕ":"جح","ﰖ":"جم","ﰗ":"حج","ﰘ":"حم","ﰙ":"خج","ﰚ":"خح","ﰛ":"خم","ﰜ":"سج","ﰝ":"سح","ﰞ":"سخ","ﰟ":"سم","ﰠ":"صح","ﰡ":"صم","ﰢ":"ضج","ﰣ":"ضح","ﰤ":"ضخ","ﰥ":"ضم","ﰦ":"طح","ﰧ":"طم","ﰨ":"ظم","ﰩ":"عج","ﰪ":"عم","ﰫ":"غج","ﰬ":"غم","ﰭ":"فج","ﰮ":"فح","ﰯ":"فخ","ﰰ":"فم","ﰱ":"فى","ﰲ":"في","ﰳ":"قح","ﰴ":"قم","ﰵ":"قى","ﰶ":"قي","ﰷ":"كا","ﰸ":"كج","ﰹ":"كح","ﰺ":"كخ","ﰻ":"كل","ﰼ":"كم","ﰽ":"كى","ﰾ":"كي","ﰿ":"لج","ﱀ":"لح","ﱁ":"لخ","ﱂ":"لم","ﱃ":"لى","ﱄ":"لي","ﱅ":"مج","ﱆ":"مح","ﱇ":"مخ","ﱈ":"مم","ﱉ":"مى","ﱊ":"مي","ﱋ":"نج","ﱌ":"نح","ﱍ":"نخ","ﱎ":"نم","ﱏ":"نى","ﱐ":"ني","ﱑ":"هج","ﱒ":"هم","ﱓ":"هى","ﱔ":"هي","ﱕ":"يج","ﱖ":"يح","ﱗ":"يخ","ﱘ":"يم","ﱙ":"يى","ﱚ":"يي","ﱛ":"ذٰ","ﱜ":"رٰ","ﱝ":"ىٰ","ﱞ":" ٌّ","ﱟ":" ٍّ","ﱠ":" َّ","ﱡ":" ُّ","ﱢ":" ِّ","ﱣ":" ّٰ","ﱤ":"ئر","ﱥ":"ئز","ﱦ":"ئم","ﱧ":"ئن","ﱨ":"ئى","ﱩ":"ئي","ﱪ":"بر","ﱫ":"بز","ﱬ":"بم","ﱭ":"بن","ﱮ":"بى","ﱯ":"بي","ﱰ":"تر","ﱱ":"تز","ﱲ":"تم","ﱳ":"تن","ﱴ":"تى","ﱵ":"تي","ﱶ":"ثر","ﱷ":"ثز","ﱸ":"ثم","ﱹ":"ثن","ﱺ":"ثى","ﱻ":"ثي","ﱼ":"فى","ﱽ":"في","ﱾ":"قى","ﱿ":"قي","ﲀ":"كا","ﲁ":"كل","ﲂ":"كم","ﲃ":"كى","ﲄ":"كي","ﲅ":"لم","ﲆ":"لى","ﲇ":"لي","ﲈ":"ما","ﲉ":"مم","ﲊ":"نر","ﲋ":"نز","ﲌ":"نم","ﲍ":"نن","ﲎ":"نى","ﲏ":"ني","ﲐ":"ىٰ","ﲑ":"ير","ﲒ":"يز","ﲓ":"يم","ﲔ":"ين","ﲕ":"يى","ﲖ":"يي","ﲗ":"ئج","ﲘ":"ئح","ﲙ":"ئخ","ﲚ":"ئم","ﲛ":"ئه","ﲜ":"بج","ﲝ":"بح","ﲞ":"بخ","ﲟ":"بم","ﲠ":"به","ﲡ":"تج","ﲢ":"تح","ﲣ":"تخ","ﲤ":"تم","ﲥ":"ته","ﲦ":"ثم","ﲧ":"جح","ﲨ":"جم","ﲩ":"حج","ﲪ":"حم","ﲫ":"خج","ﲬ":"خم","ﲭ":"سج","ﲮ":"سح","ﲯ":"سخ","ﲰ":"سم","ﲱ":"صح","ﲲ":"صخ","ﲳ":"صم","ﲴ":"ضج","ﲵ":"ضح","ﲶ":"ضخ","ﲷ":"ضم","ﲸ":"طح","ﲹ":"ظم","ﲺ":"عج","ﲻ":"عم","ﲼ":"غج","ﲽ":"غم","ﲾ":"فج","ﲿ":"فح","ﳀ":"فخ","ﳁ":"فم","ﳂ":"قح","ﳃ":"قم","ﳄ":"كج","ﳅ":"كح","ﳆ":"كخ","ﳇ":"كل","ﳈ":"كم","ﳉ":"لج","ﳊ":"لح","ﳋ":"لخ","ﳌ":"لم","ﳍ":"له","ﳎ":"مج","ﳏ":"مح","ﳐ":"مخ","ﳑ":"مم","ﳒ":"نج","ﳓ":"نح","ﳔ":"نخ","ﳕ":"نم","ﳖ":"نه","ﳗ":"هج","ﳘ":"هم","ﳙ":"هٰ","ﳚ":"يج","ﳛ":"يح","ﳜ":"يخ","ﳝ":"يم","ﳞ":"يه","ﳟ":"ئم","ﳠ":"ئه","ﳡ":"بم","ﳢ":"به","ﳣ":"تم","ﳤ":"ته","ﳥ":"ثم","ﳦ":"ثه","ﳧ":"سم","ﳨ":"سه","ﳩ":"شم","ﳪ":"شه","ﳫ":"كل","ﳬ":"كم","ﳭ":"لم","ﳮ":"نم","ﳯ":"نه","ﳰ":"يم","ﳱ":"يه","ﳲ":"ـَّ","ﳳ":"ـُّ","ﳴ":"ـِّ","ﳵ":"طى","ﳶ":"طي","ﳷ":"عى","ﳸ":"عي","ﳹ":"غى","ﳺ":"غي","ﳻ":"سى","ﳼ":"سي","ﳽ":"شى","ﳾ":"شي","ﳿ":"حى","ﴀ":"حي","ﴁ":"جى","ﴂ":"جي","ﴃ":"خى","ﴄ":"خي","ﴅ":"صى","ﴆ":"صي","ﴇ":"ضى","ﴈ":"ضي","ﴉ":"شج","ﴊ":"شح","ﴋ":"شخ","ﴌ":"شم","ﴍ":"شر","ﴎ":"سر","ﴏ":"صر","ﴐ":"ضر","ﴑ":"طى","ﴒ":"طي","ﴓ":"عى","ﴔ":"عي","ﴕ":"غى","ﴖ":"غي","ﴗ":"سى","ﴘ":"سي","ﴙ":"شى","ﴚ":"شي","ﴛ":"حى","ﴜ":"حي","ﴝ":"جى","ﴞ":"جي","ﴟ":"خى","ﴠ":"خي","ﴡ":"صى","ﴢ":"صي","ﴣ":"ضى","ﴤ":"ضي","ﴥ":"شج","ﴦ":"شح","ﴧ":"شخ","ﴨ":"شم","ﴩ":"شر","ﴪ":"سر","ﴫ":"صر","ﴬ":"ضر","ﴭ":"شج","ﴮ":"شح","ﴯ":"شخ","ﴰ":"شم","ﴱ":"سه","ﴲ":"شه","ﴳ":"طم","ﴴ":"سج","ﴵ":"سح","ﴶ":"سخ","ﴷ":"شج","ﴸ":"شح","ﴹ":"شخ","ﴺ":"طم","ﴻ":"ظم","ﴼ":"اً","ﴽ":"اً","ﵐ":"تجم","ﵑ":"تحج","ﵒ":"تحج","ﵓ":"تحم","ﵔ":"تخم","ﵕ":"تمج","ﵖ":"تمح","ﵗ":"تمخ","ﵘ":"جمح","ﵙ":"جمح","ﵚ":"حمي","ﵛ":"حمى","ﵜ":"سحج","ﵝ":"سجح","ﵞ":"سجى","ﵟ":"سمح","ﵠ":"سمح","ﵡ":"سمج","ﵢ":"سمم","ﵣ":"سمم","ﵤ":"صحح","ﵥ":"صحح","ﵦ":"صمم","ﵧ":"شحم","ﵨ":"شحم","ﵩ":"شجي","ﵪ":"شمخ","ﵫ":"شمخ","ﵬ":"شمم","ﵭ":"شمم","ﵮ":"ضحى","ﵯ":"ضخم","ﵰ":"ضخم","ﵱ":"طمح","ﵲ":"طمح","ﵳ":"طمم","ﵴ":"طمي","ﵵ":"عجم","ﵶ":"عمم","ﵷ":"عمم","ﵸ":"عمى","ﵹ":"غمم","ﵺ":"غمي","ﵻ":"غمى","ﵼ":"فخم","ﵽ":"فخم","ﵾ":"قمح","ﵿ":"قمم","ﶀ":"لحم","ﶁ":"لحي","ﶂ":"لحى","ﶃ":"لجج","ﶄ":"لجج","ﶅ":"لخم","ﶆ":"لخم","ﶇ":"لمح","ﶈ":"لمح","ﶉ":"محج","ﶊ":"محم","ﶋ":"محي","ﶌ":"مجح","ﶍ":"مجم","ﶎ":"مخج","ﶏ":"مخم","ﶒ":"مجخ","ﶓ":"همج","ﶔ":"همم","ﶕ":"نحم","ﶖ":"نحى","ﶗ":"نجم","ﶘ":"نجم","ﶙ":"نجى","ﶚ":"نمي","ﶛ":"نمى","ﶜ":"يمم","ﶝ":"يمم","ﶞ":"بخي","ﶟ":"تجي","ﶠ":"تجى","ﶡ":"تخي","ﶢ":"تخى","ﶣ":"تمي","ﶤ":"تمى","ﶥ":"جمي","ﶦ":"جحى","ﶧ":"جمى","ﶨ":"سخى","ﶩ":"صحي","ﶪ":"شحي","ﶫ":"ضحي","ﶬ":"لجي","ﶭ":"لمي","ﶮ":"يحي","ﶯ":"يجي","ﶰ":"يمي","ﶱ":"ممي","ﶲ":"قمي","ﶳ":"نحي","ﶴ":"قمح","ﶵ":"لحم","ﶶ":"عمي","ﶷ":"كمي","ﶸ":"نجح","ﶹ":"مخي","ﶺ":"لجم","ﶻ":"كمم","ﶼ":"لجم","ﶽ":"نجح","ﶾ":"جحي","ﶿ":"حجي","ﷀ":"مجي","ﷁ":"فمي","ﷂ":"بحي","ﷃ":"كمم","ﷄ":"عجم","ﷅ":"صمم","ﷆ":"سخي","ﷇ":"نجي","﹉":"‾","﹊":"‾","﹋":"‾","﹌":"‾","﹍":"_","﹎":"_","﹏":"_","ﺀ":"ء","ﺁ":"آ","ﺂ":"آ","ﺃ":"أ","ﺄ":"أ","ﺅ":"ؤ","ﺆ":"ؤ","ﺇ":"إ","ﺈ":"إ","ﺉ":"ئ","ﺊ":"ئ","ﺋ":"ئ","ﺌ":"ئ","ﺍ":"ا","ﺎ":"ا","ﺏ":"ب","ﺐ":"ب","ﺑ":"ب","ﺒ":"ب","ﺓ":"ة","ﺔ":"ة","ﺕ":"ت","ﺖ":"ت","ﺗ":"ت","ﺘ":"ت","ﺙ":"ث","ﺚ":"ث","ﺛ":"ث","ﺜ":"ث","ﺝ":"ج","ﺞ":"ج","ﺟ":"ج","ﺠ":"ج","ﺡ":"ح","ﺢ":"ح","ﺣ":"ح","ﺤ":"ح","ﺥ":"خ","ﺦ":"خ","ﺧ":"خ","ﺨ":"خ","ﺩ":"د","ﺪ":"د","ﺫ":"ذ","ﺬ":"ذ","ﺭ":"ر","ﺮ":"ر","ﺯ":"ز","ﺰ":"ز","ﺱ":"س","ﺲ":"س","ﺳ":"س","ﺴ":"س","ﺵ":"ش","ﺶ":"ش","ﺷ":"ش","ﺸ":"ش","ﺹ":"ص","ﺺ":"ص","ﺻ":"ص","ﺼ":"ص","ﺽ":"ض","ﺾ":"ض","ﺿ":"ض","ﻀ":"ض","ﻁ":"ط","ﻂ":"ط","ﻃ":"ط","ﻄ":"ط","ﻅ":"ظ","ﻆ":"ظ","ﻇ":"ظ","ﻈ":"ظ","ﻉ":"ع","ﻊ":"ع","ﻋ":"ع","ﻌ":"ع","ﻍ":"غ","ﻎ":"غ","ﻏ":"غ","ﻐ":"غ","ﻑ":"ف","ﻒ":"ف","ﻓ":"ف","ﻔ":"ف","ﻕ":"ق","ﻖ":"ق","ﻗ":"ق","ﻘ":"ق","ﻙ":"ك","ﻚ":"ك","ﻛ":"ك","ﻜ":"ك","ﻝ":"ل","ﻞ":"ل","ﻟ":"ل","ﻠ":"ل","ﻡ":"م","ﻢ":"م","ﻣ":"م","ﻤ":"م","ﻥ":"ن","ﻦ":"ن","ﻧ":"ن","ﻨ":"ن","ﻩ":"ه","ﻪ":"ه","ﻫ":"ه","ﻬ":"ه","ﻭ":"و","ﻮ":"و","ﻯ":"ى","ﻰ":"ى","ﻱ":"ي","ﻲ":"ي","ﻳ":"ي","ﻴ":"ي","ﻵ":"لآ","ﻶ":"لآ","ﻷ":"لأ","ﻸ":"لأ","ﻹ":"لإ","ﻺ":"لإ","ﻻ":"لا","ﻼ":"لا"} \ No newline at end of file diff --git a/pageindex/flash/data/script_bucket_table.json b/pageindex/flash/data/script_bucket_table.json new file mode 100644 index 000000000..2aaae17e8 --- /dev/null +++ b/pageindex/flash/data/script_bucket_table.json @@ -0,0 +1 @@ +[2,2,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,10,10,10,10,10,10,1,1,1,1,1,1,1,1,1,1,1,1,8,8,8,8,8,8,8,8,8,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,1,1,1,1,1,1,1,1,1,1,1,1,1,1,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,4,10,10,10,10,10,10,10,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,3,3,3,3,3,3,3,3,3,3,9,9,9,9,9,9,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,10,10,10,10,10,10,3,3,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,1,1,1,1,1,1,1,1,1,1,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,10,10,10,10,10,10,7,7,7,7,7,7,7,7,7,7,7,7,10,10,10,6,6,6,6,6,6,5,10,10,5,5,5,7,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,10,10,10,10,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,3,3,3,3,3,3,3,3,3,3,3,3,3,3,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,10,1,1,1,1,1,1,7,7,7,7,7,7,7,7,1,10] \ No newline at end of file diff --git a/pageindex/flash/heading_detection/__init__.py b/pageindex/flash/heading_detection/__init__.py new file mode 100644 index 000000000..db464e3e1 --- /dev/null +++ b/pageindex/flash/heading_detection/__init__.py @@ -0,0 +1,120 @@ +"""Per-page heading-candidate detection. This module builds and filters heading candidates from page blocks. It combines +numbering recognition, chapter/appendix keywords, local neighbor geometry, +font/style signals, cross-page rejection, and page-level candidate filtering +before handing candidates to outline assembly. +""" + +import json +import math +import re +import regex as regex_module # Unicode \p{...} property classes. +from pathlib import Path +from typing import Any, Optional +from ..outline_assembly import HeadingCandidate, OutlineNode +from ..labels import is_uppercase_dominant, trie_matches_all, advance_past_line, skip_bracketed_word, token_case_signal, format_caption_label, CaptionEntry, extract_structural_number +from ..model import ( + _UNICODE_WHITESPACE_CLASS, + _strip_diacritics, + _trim_unicode_ws, + style_key, magnitude_ratio, same_x_extent, same_y_extent, y_overlaps, left_aligned, right_aligned, center_aligned, x_aligned, x_centers_close, to_number, + last_span, avg_char_width, raw_text_of_line, heading_score, numbering_text, numbering_value, numbering_kind, Line, last_line_of, first_span_of, is_word_category, block_text, is_punct_category, deaccented_text, letter_count, punct_count, dominant_style_of, + info_weight, dominant_font_size, is_upper_dominant, is_caps_heavy, CharStats, alignment_code, Block, +) +from ..tokens import ( + is_trimmable_token, token_numeric_value, Token, TokenView, wrap_tokens, enumerate_tokens, last_token, trie_prefix_match, strip_trie_match, strip_leading_if_in, COMMA_CHARS, strip_trailing_comma, first_token, trim_trailing_punct, set_case_fold, TrieConfig, build_trie, tokenize_block, + trie_full_match, last_token_anchor, first_anchor_span, is_char_token, is_word_token, +) + +from .keyword_tables import ( + _DICT_PATH, + _DICTS, + SECTION_KEYWORDS_TRIE, + ABSTRACT_KEYWORDS_TRIE, + REFERENCES_TRIE, + APPENDIX_SECTION_TRIE, + INTRODUCTION_SECTION_TRIE, + BOX_KEYWORD_TRIE, + KEYWORDS_SECTION_TRIE, + CHAPTER_WORDS_TRIE, + APPENDIX_KEYWORDS_TRIE, + _normalize_text_key, + ABSTRACT_KEYWORDS_SET, + REFERENCES_SET, + NUMBERED_PREFIX_RE, + DEAD_DIGIT_RE, + EQUATION_KEYWORDS_TRIE, + ENGLISH_WORD_TO_NUMBER, + ROMAN_NUMERAL_MAP, + FORMULA_CHAR_WEIGHTS, +) +from .text_checks import ( + token_text_of_block, + similar_style, + is_heading_continuation, + matches_abstract, + matches_references, + vertically_close, + is_equation_adjacent_line, + has_substantive_content, + is_cover_page, + clamp, + token_to_number, + letter_to_ordinal, +) +from .neighbors import ( + BlockNeighborCache, + compute_bucket_span, + neighbor_above, + body_neighbor_above, + neighbor_right, + neighbor_right_peer, + closest_body_neighbor_above, + PageNeighborMap, +) +from .candidates import ( + PageScanState, + push_candidate, + make_heading_candidate, + make_plain_candidate, + make_body_heading_candidate, + make_numbered_candidate, + _di_count, + _number_at_token_index, +) +from .detectors import ( + detect_numbered_heading, + detect_labeled_heading, + detect_chapter_appendix, + detect_box_heading, + classify_heading, + is_acceptable_heading, + safe_column_index, + try_classify_heading, + is_too_wide_for_heading, + passes_neighbor_check, + has_competing_labeled_heading, + is_year_string, + is_bibliography_entry, +) +from .style_detectors import ( + detect_font_heading, + detect_heading_with_body, +) +from .page_scan import ( + scan_page_headings, + DocCandidateCollector, + filter_page_candidates, + build_doc_heading_candidates, + find_section_openers, +) + +__all__ = [ + "SECTION_KEYWORDS_TRIE", "ABSTRACT_KEYWORDS_TRIE", "ABSTRACT_KEYWORDS_SET", "REFERENCES_TRIE", "REFERENCES_SET", "APPENDIX_SECTION_TRIE", "INTRODUCTION_SECTION_TRIE", "BOX_KEYWORD_TRIE", "KEYWORDS_SECTION_TRIE", "CHAPTER_WORDS_TRIE", "APPENDIX_KEYWORDS_TRIE", + "ROMAN_NUMERAL_MAP", "ENGLISH_WORD_TO_NUMBER", "FORMULA_CHAR_WEIGHTS", "NUMBERED_PREFIX_RE", "DEAD_DIGIT_RE", + "is_heading_continuation", "similar_style", "matches_abstract", "matches_references", "vertically_close", "is_equation_adjacent_line", "has_substantive_content", "is_cover_page", "token_to_number", "letter_to_ordinal", + "BlockNeighborCache", "compute_bucket_span", "neighbor_above", "body_neighbor_above", "neighbor_right", "closest_body_neighbor_above", + "PageNeighborMap", "PageScanState", "DocCandidateCollector", "filter_page_candidates", + "classify_heading", "is_acceptable_heading", "try_classify_heading", "push_candidate", "detect_numbered_heading", "make_heading_candidate", "detect_labeled_heading", "make_body_heading_candidate", "detect_heading_with_body", "detect_chapter_appendix", + "is_too_wide_for_heading", "passes_neighbor_check", "make_plain_candidate", "make_numbered_candidate", "has_competing_labeled_heading", "detect_font_heading", "scan_page_headings", "detect_box_heading", + "build_doc_heading_candidates", +] diff --git a/pageindex/flash/heading_detection/candidates.py b/pageindex/flash/heading_detection/candidates.py new file mode 100644 index 000000000..a5d963927 --- /dev/null +++ b/pageindex/flash/heading_detection/candidates.py @@ -0,0 +1,214 @@ +"""Page scan state and heading-candidate constructors.""" + +from __future__ import annotations + +import math +from typing import Any, Optional +from ..outline_assembly import HeadingCandidate, OutlineNode +from ..labels import is_uppercase_dominant, trie_matches_all, advance_past_line, skip_bracketed_word, token_case_signal, format_caption_label, CaptionEntry, extract_structural_number +from ..model import ( + _UNICODE_WHITESPACE_CLASS, + _strip_diacritics, + _trim_unicode_ws, + style_key, magnitude_ratio, same_x_extent, same_y_extent, y_overlaps, left_aligned, right_aligned, center_aligned, x_aligned, x_centers_close, to_number, + last_span, avg_char_width, raw_text_of_line, heading_score, numbering_text, numbering_value, numbering_kind, Line, last_line_of, first_span_of, is_word_category, block_text, is_punct_category, deaccented_text, letter_count, punct_count, dominant_style_of, + info_weight, dominant_font_size, is_upper_dominant, is_caps_heavy, CharStats, alignment_code, Block, +) +from ..tokens import ( + is_trimmable_token, token_numeric_value, Token, TokenView, wrap_tokens, enumerate_tokens, last_token, trie_prefix_match, strip_trie_match, strip_leading_if_in, COMMA_CHARS, strip_trailing_comma, first_token, trim_trailing_punct, set_case_fold, TrieConfig, build_trie, tokenize_block, + trie_full_match, last_token_anchor, first_anchor_span, is_char_token, is_word_token, +) + +from .text_checks import matches_references +from .neighbors import ( + neighbor_right, + closest_body_neighbor_above, + PageNeighborMap, +) + + +# --------------------------------------------------------------------------- # +# Main per-page heading state # +# --------------------------------------------------------------------------- # + + +class PageScanState: + """Per-page heading scan state.""" + + __slots__ = ("secondary_slot", "primary_slot", "state_slot", "auxiliary_slot", "tertiary_slot", "option_slot", "measure_slot") + + def __init__(self, doc, page): + self.secondary_slot = doc # document state + self.primary_slot = page + self.state_slot = doc.primary_slot[page.page_index - 2] if page.page_index >= 2 else None # prev page + self.auxiliary_slot = page.output_slot # blocks in original order + self.tertiary_slot = PageNeighborMap(page) # neighbor map + self.option_slot: list[HeadingCandidate] = [] # output candidates + self.measure_slot: set = set() # set of block ids already pushed + + +# --------------------------------------------------------------------------- # +# Push heading candidate into page state # +# --------------------------------------------------------------------------- # + + +def push_candidate(page_scan: PageScanState, candidate: HeadingCandidate) -> None: + """Push a candidate into the page scan state.""" + page_scan.option_slot.append(candidate) + page_scan.measure_slot.add(candidate.group_slot) + + +# --------------------------------------------------------------------------- # +# Heading-candidate builder. +# --------------------------------------------------------------------------- # + + +def make_heading_candidate(page_scan: PageScanState, type_: int, block: Block, item_list: list[int], + tokens: Optional[TokenView], title_tokens: Optional[TokenView], has_numbering_flag: bool = False) -> HeadingCandidate: + """Build a heading candidate and apply the spatial promotion rule.""" + neighbor = page_scan.tertiary_slot + right_neighbor = neighbor_right(neighbor, block) + # Spatial promotion to structural numbering: if a right-side neighbour exists, the block + # has high skew (real horizontal text), its title ends in a colon-like + # symbol, and its last line is nearly as wide as and right-aligned to + # the neighbour -> promote the flag to true. + if ( + not has_numbering_flag and right_neighbor is not None + and block.previous_slot > 0.9 and title_tokens is not None + ): + last_title_token = last_token(title_tokens) + if last_title_token is not None and is_trimmable_token(last_title_token): + mh_block = last_line_of(block) + if ( + mh_block.bbox_width() > 0.7 * right_neighbor.bbox_width() + and abs(mh_block.right_edge() - right_neighbor.right_edge()) < 2 * avg_char_width(mh_block) + ): + has_numbering_flag = True + prominent_flag = ( + type_ == 7 + or (len(item_list) > 0 and title_tokens is not None and matches_references(title_tokens)) + ) + return HeadingCandidate( + type_=type_, + page=page_scan.primary_slot, + group_value=block, + anchor=closest_body_neighbor_above(neighbor, block), + numbering_value=item_list, + tokens=tokens, + title_tokens=title_tokens, + has_numbering_flag=has_numbering_flag, + prominent_flag=prominent_flag, + ) + + +# --------------------------------------------------------------------------- # +# Shorthand heading-candidate builders # +# --------------------------------------------------------------------------- # + + +def make_plain_candidate(page_scan: PageScanState, type_: int, block: Block) -> HeadingCandidate: + """Build a type-only candidate using the full block text.""" + return make_heading_candidate(page_scan, type_, block, [], None, tokenize_block(block), False) + + +def make_body_heading_candidate(page_scan: PageScanState, type_: int, block: Block, tokens: TokenView) -> HeadingCandidate: + """Build a candidate from body-heading tokens.""" + return make_heading_candidate(page_scan, type_, block, [], None, trim_trailing_punct(tokens), True) + + +# --------------------------------------------------------------------------- # +# Composed-number heading-candidate builder # +# --------------------------------------------------------------------------- # + + +def make_numbered_candidate(page_scan: PageScanState, block: Block, item_list: list[int], tokens: TokenView, title_tokens: TokenView) -> Optional[HeadingCandidate]: + """Build a numbered-heading candidate after the full reject-guard chain. The guard rejects empty numbering, weak single-token numbering, unsupported top-of-page continuations, alignment failures, and trailing-number continuation conflicts.""" + from ..labels import extract_structural_number # numbering-prefix detector + + # Basic reject branch for empty, weak, or top-of-page continuation markers. + if title_tokens.length <= 0: + return None + first_title_token = first_token(title_tokens) + if (title_tokens.length == 1 and first_title_token is not None + and first_title_token.primary_slot != 2 and first_title_token.primary_slot != 4 and first_title_token.secondary_slot != 2 + and not block.isolated_centered): + return None + if len(item_list) == 1 and item_list[0] == 1 and block.top_edge() < 0.3 * page_scan.primary_slot.bounds.bbox_height(): + from ..heading_detection import neighbor_right + if neighbor_right(page_scan.tertiary_slot, block) is None: + last_title_token = last_token(title_tokens) + if last_title_token is not None and last_title_token.anchor_ranges and last_title_token.anchor_ranges[-1].line is last_line_of(block): + return None + + # If basic guards didn't trigger, examine multi-line patterns. + reject = False + if block.line_count() > 1: + second_line = block.primary_slot[1] + first_number_token = first_token(tokens) + first_title_token = first_token(title_tokens) + if first_number_token is not None and first_title_token is not None: + left = first_anchor_span(first_number_token).left_edge() + title_left = first_anchor_span(first_title_token).left_edge() + if not (left < title_left and second_line.left_edge() > (left + title_left) / 2): + # Check trailing tokens for c+1 continuation + trailing_tokens = tokenize_block(block) + trailing_tokens = trailing_tokens.slice(_di_count(trailing_tokens, block.line())) + trailing_tokens = extract_structural_number(trailing_tokens) + if trailing_tokens is None or trailing_tokens.length <= 0: + reject = False + elif block.measure_slot: + reject = True + else: + if len(item_list) == 1 and trailing_tokens.length <= 2: + trailing_first_token = trailing_tokens.token_at(0) + if trailing_first_token is not None: + value = token_numeric_value(trailing_first_token) + # Strict equality on the raw Number, no truncation + # (a fractional value never + # equals the integer c[0]+1). + reject = (not math.isnan(value) and value == item_list[0] + 1) + else: + reject = False + else: + reject = False + if reject: + return None + return make_heading_candidate(page_scan, 1, block, item_list, tokens, title_tokens, False) + + +def _di_count(tokens: TokenView, line) -> int: + """count tokens belonging to ``line`` starting from index 0.""" + count_item = 0 + for index_value in range(tokens.length): + token_value = tokens.token_at(index_value) + if token_value is None or token_value.line() is not line: + break + count_item += 1 + return count_item + + +# --------------------------------------------------------------------------- # +# Numbered heading detector # +# --------------------------------------------------------------------------- # + + +def _number_at_token_index(tokens: TokenView, index: int) -> int: + """Try to extract a numbering value at index ``b_idx`` of a token view. Returns 0 if not a number-followed-by-separator, else the number. """ + if tokens.length < index + 2: + return 0 + token = tokens.token_at(index) + if token is None or token.type != 1: + return 0 + next_tok = tokens.token_at(index + 1) + if next_tok is None: + return 0 + from ..labels import PERIOD_CHARS as period_chars + if not ( + next_tok.str in period_chars + or next_tok.str in (")", "]", ".", "。", "。", ")", "]", "】") + ): + return 0 + val = token_numeric_value(token) + if math.isnan(val) or val <= 0 or val >= 1000: + return 0 + return int(val) diff --git a/pageindex/flash/heading_detection/detectors.py b/pageindex/flash/heading_detection/detectors.py new file mode 100644 index 000000000..4a916807f --- /dev/null +++ b/pageindex/flash/heading_detection/detectors.py @@ -0,0 +1,492 @@ +"""Numbered, labeled, chapter/appendix, and box heading detectors plus acceptability checks.""" + +from __future__ import annotations + +import math +from typing import Any, Optional +from ..outline_assembly import HeadingCandidate, OutlineNode +from ..labels import is_uppercase_dominant, trie_matches_all, advance_past_line, skip_bracketed_word, token_case_signal, format_caption_label, CaptionEntry, extract_structural_number +from ..model import ( + _UNICODE_WHITESPACE_CLASS, + _strip_diacritics, + _trim_unicode_ws, + style_key, magnitude_ratio, same_x_extent, same_y_extent, y_overlaps, left_aligned, right_aligned, center_aligned, x_aligned, x_centers_close, to_number, + last_span, avg_char_width, raw_text_of_line, heading_score, numbering_text, numbering_value, numbering_kind, Line, last_line_of, first_span_of, is_word_category, block_text, is_punct_category, deaccented_text, letter_count, punct_count, dominant_style_of, + info_weight, dominant_font_size, is_upper_dominant, is_caps_heavy, CharStats, alignment_code, Block, +) +from ..tokens import ( + is_trimmable_token, token_numeric_value, Token, TokenView, wrap_tokens, enumerate_tokens, last_token, trie_prefix_match, strip_trie_match, strip_leading_if_in, COMMA_CHARS, strip_trailing_comma, first_token, trim_trailing_punct, set_case_fold, TrieConfig, build_trie, tokenize_block, + trie_full_match, last_token_anchor, first_anchor_span, is_char_token, is_word_token, +) + +from .keyword_tables import ( + APPENDIX_SECTION_TRIE, + BOX_KEYWORD_TRIE, + CHAPTER_WORDS_TRIE, + APPENDIX_KEYWORDS_TRIE, + ROMAN_NUMERAL_MAP, +) +from .text_checks import ( + similar_style, + matches_abstract, + matches_references, + has_substantive_content, + clamp, + token_to_number, + letter_to_ordinal, +) +from .neighbors import ( + neighbor_above, + body_neighbor_above, + neighbor_right, + closest_body_neighbor_above, +) +from .candidates import ( + PageScanState, + make_heading_candidate, + make_plain_candidate, + make_numbered_candidate, +) + + +def detect_numbered_heading(page_scan: PageScanState, block: Block, tokens: TokenView) -> Optional[HeadingCandidate]: + """Identify "1.2.3" / "[1]" style numbered heading prefixes.""" + item_list: list[int] = [] + at_value = None + for entry in enumerate_tokens(tokens): + index = entry["index"] + token = entry["token"] + if token.type == 1: + if len(item_list) >= 4: + break + val = token_numeric_value(token) + if math.isnan(val) or val <= 0 or val >= 20: + break + if len(token.str) >= 3: + break + item_list.append(int(val)) + if not token.boundary_slot: + continue + at_value = tokens.token_at(index + 1) + if ( + index + 2 < tokens.length + and at_value is not None + and (is_word_token(at_value) or at_value.type == 6) + and tokens.token_at(index + 2) is not None + and tokens.token_at(index + 2).type == 1 + ): + break + # Strong numbering, prominent style, or a viable separator token is + # enough to build a numbered-heading candidate. + if ( + len(item_list) > 1 + or heading_score(block) > page_scan.primary_slot.primary_slot.primary_slot + 1 + or (not block.measure_slot and at_value is not None and ( + at_value.primary_slot in (2, 4) or at_value.secondary_slot == 2 or at_value.type == 4 + or at_value.str == "." or at_value.str == "|" + )) + ): + return make_numbered_candidate( + page_scan, block, item_list, + tokens.slice(0, index + 1), + tokens.slice(index + 1), + ) + return None + # Accept period-like punctuation or a symbol token as a numbering + # separator. + if token.str in (".", ".", "。", "。") or token.type == 4: + prev = tokens.token_at(index - 1) # token_at(-1) returns None + if prev is None or prev.type != 1: + break + if not token.boundary_slot: + continue + return make_numbered_candidate( + page_scan, block, item_list, + tokens.slice(0, index + 1), tokens.slice(index + 1), + ) + if len(item_list) <= 0 or token.type != 2: + break + if token.primary_slot not in (2, 4): + break + if ( + len(item_list) > 1 + or len(token.str) >= 3 + or tokens.length - index >= 3 + ): + return make_numbered_candidate( + page_scan, block, item_list, + tokens.slice(0, index), tokens.slice(index), + ) + return None + return None + + +# --------------------------------------------------------------------------- # +# Complex numbering format detector. +# --------------------------------------------------------------------------- # + + +def detect_labeled_heading(page_scan: PageScanState, block: Block, tokens: TokenView) -> Optional[HeadingCandidate]: + """Detect Roman, letter, CJK, and mixed-numbering headings.""" + if tokens.length <= 1: + return None + first = tokens.token_at(0) + second = tokens.token_at(1) + if first is None or second is None: + return None + # Roman numeral path + roman = ROMAN_NUMERAL_MAP.get(first.str) + if roman is not None and is_word_token(second) and second.str in "..。。:)": + prefix = tokens.slice(0, 2) + return make_heading_candidate(page_scan, 2, block, [roman], prefix, tokens.slice(prefix.length)) + # CJK number path + cjk_pos = "一二三四五六七八九十".find(first.str) + if cjk_pos >= 0 and is_word_token(second): + prefix = tokens.slice(0, 2) + rest = tokens.slice(prefix.length) + if rest.length <= 0: + return None + return make_heading_candidate(page_scan, 3, block, [cjk_pos + 1], prefix, rest) + # Letter path + if tokens.length <= 1 or (block.char_stats.secondary_slot == 3 and (block.line_count() > 1 or is_punct_category(block.char_stats.tertiary_slot))): + return None + letter_val = letter_to_ordinal(first.str) + if letter_val is None: + return None + if second.str == "." or second.str == ")": + value = letter_val + else: + first_anchor = first_anchor_span(first) + second_anchor = first_anchor_span(second) + if ( + not first.boundary_slot + or first_anchor is second_anchor + or second_anchor.left_edge() < first_anchor.right_edge() + first_anchor.bbox_width() + or heading_score(block) < page_scan.primary_slot.primary_slot.primary_slot + 1 + or letter_count(block.char_stats) / tokens.length < 2 + ): + return None + value = letter_val + item_list: list[int] = [value] + prefix = tokens.slice(0, 2 if is_word_token(second) else 1) + rest = tokens.slice(prefix.length) + if second.str == "." and not second.boundary_slot and rest.length >= 2: + first_rest = first_token(rest) + if first_rest is not None and first_rest.type == 1: + heading = token_numeric_value(first_rest) + if math.isnan(heading) or heading <= 0 or heading >= 20: + return None + item_list.append(int(heading)) + rest = rest.slice(1) + first_rest = first_token(rest) + if rest.length > 0 and first_rest is not None and is_word_token(first_rest): + rest = rest.slice(1) + if rest.length <= 0: + return None + prefix = tokens.slice(0, tokens.length - rest.length) + return make_heading_candidate(page_scan, 4, block, item_list, prefix, rest) + + +# --------------------------------------------------------------------------- # +# Chapter, appendix, and box-style dispatch. +# --------------------------------------------------------------------------- # + + +def detect_chapter_appendix(page_scan: PageScanState, other_block: Block) -> Optional[HeadingCandidate]: + """. Match "Chapter X" / "Appendix X" / box-N / etc.""" + candidate_item = heading_score(other_block) + if candidate_item <= page_scan.primary_slot.primary_slot.primary_slot + 0.1: + return None + flag = ( + other_block.isolated_centered or candidate_item > page_scan.secondary_slot.secondary_slot.primary_slot + 0.1 + and (other_block.bold_frac() > 0.9 or is_upper_dominant(other_block.char_stats) or candidate_item > 1.5 * page_scan.secondary_slot.secondary_slot.primary_slot) + ) + tokens = tokenize_block(other_block) + match = None + if flag: + match = trie_prefix_match(CHAPTER_WORDS_TRIE, tokens) + if flag and match is not None: + value = token_to_number(tokens.token_at(match.length)) + if value is None: + return None + prefix = tokens.slice(0, skip_bracketed_word(tokens, match.length + 1)) + return make_heading_candidate(page_scan, 8, other_block, [value], prefix, tokens.slice(prefix.length)) + if flag: + match = trie_prefix_match(APPENDIX_SECTION_TRIE, tokens) + if match is not None: + prefix = tokens.slice(0, skip_bracketed_word(tokens, match.length)) + return make_heading_candidate(page_scan, 9, other_block, [], prefix, tokens.slice(prefix.length)) + match = trie_prefix_match(APPENDIX_KEYWORDS_TRIE, tokens) + if match is not None: + next_item = tokens.token_at(match.length) + val = token_to_number(next_item) or (letter_to_ordinal(next_item.str) if next_item is not None else None) + if not flag and val is None: + return None + item_list = [val] if val is not None else [] + prefix = tokens.slice(0, skip_bracketed_word(tokens, match.length + (1 if val is not None else 0))) + return make_heading_candidate(page_scan, 10, other_block, item_list, prefix, tokens.slice(prefix.length)) + return None + + +# --------------------------------------------------------------------------- # +# Box-format heading. +# --------------------------------------------------------------------------- # + + +def detect_box_heading(page_scan: PageScanState, other_block: Block) -> Optional[HeadingCandidate]: + """match "Box N" pattern.""" + tokens = tokenize_block(other_block) + match = trie_prefix_match(BOX_KEYWORD_TRIE, tokens) + if match is None: + return None + rest = tokens.slice(match.length) + if rest.length <= 0 or rest.token_at(0).type != 1: + return None + val = token_numeric_value(rest.token_at(0)) + if math.isnan(val) or val <= 0: + return None + prefix = tokens.slice(0, skip_bracketed_word(tokens, match.length + 1)) + return make_heading_candidate(page_scan, 12, other_block, [int(val)], prefix, tokens.slice(prefix.length)) + + +# --------------------------------------------------------------------------- # +# Heading-type dispatcher. +# --------------------------------------------------------------------------- # + + +def classify_heading(page_scan: PageScanState, other_block: Block) -> HeadingCandidate: + """. Sequential dispatch through type detectors; fallback to the font-position classifier.""" + tokens = tokenize_block(other_block) + heading = detect_chapter_appendix(page_scan, other_block) + if heading is None: + heading = detect_box_heading(page_scan, other_block) + if heading is None: + heading = detect_numbered_heading(page_scan, other_block, tokens) + if heading is None: + heading = detect_labeled_heading(page_scan, other_block, tokens) + if heading is not None: + return heading + type_code = 7 if matches_references(tokens) else (5 if matches_abstract(tokens) else 0) + return make_plain_candidate(page_scan, type_code, other_block) + + +# --------------------------------------------------------------------------- # +# Heading acceptance gate. +# --------------------------------------------------------------------------- # + + +def is_acceptable_heading(page_scan: PageScanState, other_heading_candidate: HeadingCandidate) -> bool: + """. The big "is this an acceptable heading?" gate.""" + heading = other_heading_candidate.group_slot + if heading.bbox_height() >= 2 * heading.bbox_width() or info_weight(heading.char_stats) <= 3 or heading.line_count() > 5 or heading.char_count() >= 300: + return False + page_height = page_scan.primary_slot.bounds.bbox_height() + if heading.bottom_edge() > 0.95 * page_height: + return False + score = heading_score(heading) + doc_group = page_scan.secondary_slot.secondary_slot.primary_slot + if score <= page_scan.primary_slot.primary_slot.primary_slot + 0.5 and score <= doc_group + 0.5 and not other_heading_candidate.is_prominent: + return False + width = page_scan.primary_slot.bounds.bbox_width() + if ( + (heading.left_edge() > 0.55 * width and score <= doc_group + 5) + or heading.left_edge() > 0.75 * width + or ( + heading.left_edge() > 0.4 * width + and heading.center_x() > 0.6 * width + and page_scan.primary_slot.primary_slot.secondary_slot > min(1000, page_scan.secondary_slot.secondary_slot.secondary_slot) + ) + ): + return False + col_bottom = page_scan.primary_slot.tertiary_slot[safe_column_index(heading)] if 0 <= safe_column_index(heading) < len(page_scan.primary_slot.tertiary_slot) else None + if ( + heading.bbox_width() < 0.2 * width and col_bottom is not None + and col_bottom.bbox_width() < 0.2 * width and col_bottom.bbox_height() > 1.5 * col_bottom.bbox_width() + ): + return False + neighbor = neighbor_right(page_scan.tertiary_slot, heading) + gap = heading.bottom_edge() - neighbor.top_edge() if neighbor is not None else math.inf + line_gap = page_scan.primary_slot.primary_slot.tertiary_slot - page_scan.primary_slot.primary_slot.primary_slot + if gap < 0.9 * line_gap: + return False + above = neighbor_above(page_scan.tertiary_slot, heading) + above_gap = above.bottom_edge() - heading.top_edge() if above is not None else math.inf + if above_gap < 0.9 * line_gap: + return False + if ( + page_scan.state_slot is not None + and not page_scan.state_slot.measure_slot + and ( + page_scan.state_slot.primary_slot.secondary_slot < clamp(page_scan.secondary_slot.secondary_slot.secondary_slot, 200, 500) + or not page_scan.state_slot.state_slot + ) + and score > doc_group + 0.5 + ): + return True + doc_right_neighbor = page_scan.secondary_slot.secondary_slot.measure_slot + previous_page_left_neighbor = page_scan.state_slot.primary_slot.option_slot if page_scan.state_slot is not None else math.nan + previous_page_height = page_scan.state_slot.bounds.bbox_height() if page_scan.state_slot is not None else math.nan + centered_flag = heading.isolated_centered + if ( + previous_page_left_neighbor <= doc_right_neighbor + and (score <= doc_group + 1.5 or (score <= doc_group + 5 and not centered_flag)) + or heading.weighted_ratio_primary < 0.5 * page_scan.secondary_slot.secondary_slot.auxiliary_slot + ): + return False + body_neighbor = closest_body_neighbor_above(page_scan.tertiary_slot, heading) + # Reject candidates that are separated from a classified above-neighbor, or + # whose own content is more formula-like than heading-like. + if body_neighbor is not None and heading.bottom_edge() - body_neighbor.top_edge() > 2 * heading.bbox_height() and body_neighbor.marker_slot != 0: + return False + previous_block = page_scan.auxiliary_slot[heading.orig_index - 1] if 0 <= heading.orig_index - 1 < len(page_scan.auxiliary_slot) else None + next_block = page_scan.auxiliary_slot[heading.orig_index + 1] if 0 <= heading.orig_index + 1 < len(page_scan.auxiliary_slot) else None + if has_substantive_content(heading, previous_block, next_block): + return False + return ( + (previous_page_left_neighbor > doc_right_neighbor + 0.1 * previous_page_height + and (score > doc_group + 2 + or (previous_page_left_neighbor > doc_right_neighbor + 0.2 * previous_page_height + and body_neighbor is not None and neighbor is not None + and gap > neighbor.avg_font_size()))) + or (centered_flag and (neighbor is None or neighbor.marker_slot == 0)) + or score > 1.5 * doc_group + or (len(other_heading_candidate.numbering) == 1 and other_heading_candidate.numbering[0] == 1 and above is None) + ) + + +def safe_column_index(block) -> int: + """Safe wrapper for hh that handles missing H field.""" + from ..stats import column_index_of + # Empty containers must return -1; returning 0 would index a real column. + return column_index_of(block) + + +# --------------------------------------------------------------------------- # +# Heading classification + acceptability gate # +# --------------------------------------------------------------------------- # + + +def try_classify_heading(page_scan: PageScanState, other_block: Block) -> Optional[HeadingCandidate]: + """Try to build a candidate for a block, then apply rejection gates.""" + candidate = classify_heading(page_scan, other_block) + if len(candidate.numbering) > 1: + return None + if candidate.type in (8, 9, 10): + return candidate + if candidate.type == 12: + return None + return candidate if is_acceptable_heading(page_scan, candidate) else None + + +# --------------------------------------------------------------------------- # +# Additional heading detectors and neighbor gates. +# --------------------------------------------------------------------------- # + + +def is_too_wide_for_heading(page_scan: PageScanState, other_block: Block) -> bool: + """. Block is too wide / central to be a heading.""" + width = other_block.bbox_width() + if width > 0.7 * page_scan.primary_slot.bounds.bbox_width() / 2 or width > 0.7 * page_scan.secondary_slot.secondary_slot.option_slot: + return True + count = 0 + for heading in range(page_scan.primary_slot.page_index - 1, page_scan.primary_slot.page_index + 2): + if 0 < heading <= len(page_scan.secondary_slot.primary_slot): + page = page_scan.secondary_slot.primary_slot[heading - 1] + if width > 0.7 * page.primary_slot.previous_slot: + count += 1 + return count >= 2 + + +def passes_neighbor_check(page_scan: PageScanState, other_block: Block) -> bool: + """Block-level neighbor-aware acceptance gate. Returns True when the caller should reject the block.""" + if is_too_wide_for_heading(page_scan, other_block): + return False + blocks = page_scan.auxiliary_slot + prev_idx = other_block.orig_index - 1 + candidate_item = blocks[prev_idx] if 0 <= prev_idx < len(blocks) else None + overlap = candidate_item is not None and y_overlaps(other_block, candidate_item) + if overlap and is_too_wide_for_heading(page_scan, candidate_item): + return False + next_idx = other_block.orig_index + 1 + candidate_item = blocks[next_idx] if 0 <= next_idx < len(blocks) else None + next_overlap = candidate_item is not None and y_overlaps(other_block, candidate_item) + if next_overlap and is_too_wide_for_heading(page_scan, candidate_item): + return False + if not overlap and not next_overlap: + return False + candidate_item = neighbor_right(page_scan.tertiary_slot, other_block) + if candidate_item is not None and is_too_wide_for_heading(page_scan, candidate_item): + return False + if candidate_item is not None and not candidate_item.is_body_paragraph and candidate_item.line_count() > 3 and candidate_item.bbox_height() > 0.8 * candidate_item.bbox_width(): + return True + # Compare against the closest above-neighbor with a width threshold derived + # from this block's first line. + above_or_overlap = closest_body_neighbor_above(page_scan.tertiary_slot, other_block) + threshold = 4 * avg_char_width(other_block.line()) + if (above_or_overlap is not None and candidate_item is not above_or_overlap + and x_aligned(other_block, above_or_overlap, threshold) + and above_or_overlap.state_slot == 0 + and is_too_wide_for_heading(page_scan, above_or_overlap)): + return False + keyword_match = body_neighbor_above(page_scan.tertiary_slot, other_block) + if (keyword_match is not None + and x_aligned(other_block, keyword_match, threshold) + and keyword_match.state_slot == 0 + and is_too_wide_for_heading(page_scan, keyword_match)): + return False + return True + + +def has_competing_labeled_heading(page_scan: PageScanState, other_heading_candidate: HeadingCandidate, candidate_block: Block) -> bool: + """Cross-page reject check for competing labeled-heading siblings.""" + if candidate_block.type != 0 or candidate_block.char_count() >= 500: + return False + block = other_heading_candidate.group_slot + if not similar_style(block, candidate_block) or abs(block.top_edge() - candidate_block.top_edge()) >= 5 * block.bbox_height(): + return False + other_candidate = detect_labeled_heading(page_scan, candidate_block, tokenize_block(candidate_block)) + if other_candidate is None or other_heading_candidate.type != other_candidate.type: + return False + return abs(other_candidate.numbering[0] - other_heading_candidate.numbering[0]) >= 1 + + +def is_year_string(text: str) -> bool: + """True iff the text parses to a plausible year (1700..2100).""" + value = to_number(text) + return not math.isnan(value) and 1700 < value < 2100 + + +def is_bibliography_entry(block: Block, other_number: int = -1) -> bool: + """True iff ``block`` looks like a bibliography entry.""" + if other_number < 0: + other_number = 0 + for line in block: + reference_item = numbering_value(line) + if not math.isnan(reference_item) and 0 < reference_item <= 9999: + other_number += 1 + if other_number < 2 and block.char_count() / max(1, other_number) > 300: + return False + year = 0 + digit = 0 + word = 0 + period_after_word = 0 + word_state = 0 + tokens = tokenize_block(block) + for entry in enumerate_tokens(tokens): + state_item = entry["token"] + if is_word_token(state_item): + if state_item.type == 3 and word_state == 1: + period_after_word += 1 + word_state = 0 + elif state_item.type == 1: + key_value = token_numeric_value(state_item) + if 0 < key_value < 1000: + digit += 1 + elif is_year_string(state_item.str): + year += 1 + elif state_item.type == 2: + word += 1 + word_state += 1 + if word < 0.1 * tokens.length: + return False + return digit >= 1.5 * other_number or year >= 0.5 * other_number or period_after_word >= 0.5 * other_number diff --git a/pageindex/flash/heading_detection/keyword_tables.py b/pageindex/flash/heading_detection/keyword_tables.py new file mode 100644 index 000000000..0816bf1db --- /dev/null +++ b/pageindex/flash/heading_detection/keyword_tables.py @@ -0,0 +1,96 @@ +"""Dictionary-backed keyword tries, keyword sets, and numbering tables.""" + +from __future__ import annotations + +import json +import re +import regex as regex_module # Unicode \p{...} property classes. +from pathlib import Path +from ..model import ( + _UNICODE_WHITESPACE_CLASS, + _strip_diacritics, + _trim_unicode_ws, + style_key, magnitude_ratio, same_x_extent, same_y_extent, y_overlaps, left_aligned, right_aligned, center_aligned, x_aligned, x_centers_close, to_number, + last_span, avg_char_width, raw_text_of_line, heading_score, numbering_text, numbering_value, numbering_kind, Line, last_line_of, first_span_of, is_word_category, block_text, is_punct_category, deaccented_text, letter_count, punct_count, dominant_style_of, + info_weight, dominant_font_size, is_upper_dominant, is_caps_heavy, CharStats, alignment_code, Block, +) +from ..tokens import ( + is_trimmable_token, token_numeric_value, Token, TokenView, wrap_tokens, enumerate_tokens, last_token, trie_prefix_match, strip_trie_match, strip_leading_if_in, COMMA_CHARS, strip_trailing_comma, first_token, trim_trailing_punct, set_case_fold, TrieConfig, build_trie, tokenize_block, + trie_full_match, last_token_anchor, first_anchor_span, is_char_token, is_word_token, +) + + +# --------------------------------------------------------------------------- # +# Dictionary tries (case-folded) # +# --------------------------------------------------------------------------- # + + +_DICT_PATH = Path(__file__).parent.parent / "data" / "dictionaries.json" +_DICTS = json.loads(_DICT_PATH.read_text(encoding="utf-8")) + +SECTION_KEYWORDS_TRIE = build_trie(_DICTS.get("section_keywords", []), set_case_fold(TrieConfig(), True)) # general sections +ABSTRACT_KEYWORDS_TRIE = build_trie(_DICTS.get("abstract_keywords", []), set_case_fold(TrieConfig(), True)) # abstract +REFERENCES_TRIE = build_trie(_DICTS.get("references", []), set_case_fold(TrieConfig(), True)) # references +APPENDIX_SECTION_TRIE = build_trie(_DICTS.get("appendices_dict", []), set_case_fold(TrieConfig(), True)) # appendix +INTRODUCTION_SECTION_TRIE = build_trie(_DICTS.get("introduction_dict", []), set_case_fold(TrieConfig(), True)) # introduction +BOX_KEYWORD_TRIE = build_trie(["box"], set_case_fold(TrieConfig(), True)) +KEYWORDS_SECTION_TRIE = build_trie(_DICTS.get("keywords_dict", []), set_case_fold(TrieConfig(), True)) # keywords +CHAPTER_WORDS_TRIE = build_trie(_DICTS.get("chapter_words", []), set_case_fold(TrieConfig(), True)) # chapter +APPENDIX_KEYWORDS_TRIE = build_trie(_DICTS.get("appendix_keywords", []), set_case_fold(TrieConfig(), True)) # appendix (hi) + +# Whole-text lookup sets use normalized lowercase strings. The normalization is +# NFD -> strip combining marks (U+0300-U+036F) -> NFC; it is diacritic stripping, +# not compatibility folding. +def _normalize_text_key(text: str) -> str: + return _strip_diacritics(text) + +# Whole-text lookup sets for abstract and references headings. +# Abstract headings are matched diacritic-insensitively; references are not. +ABSTRACT_KEYWORDS_SET = frozenset(_strip_diacritics(text_value.lower()) for text_value in _DICTS.get("abstract_keywords", [])) +REFERENCES_SET = frozenset(text_value.lower() for text_value in _DICTS.get("references", [])) + + +# Numbered heading prefix: leading ASCII/fullwidth 1-9, followed by Unicode +# numeric code points, punctuation, and whitespace or uppercase lookahead. The +# leading class deliberately excludes fullwidth zero (U+FF10). +NUMBERED_PREFIX_RE = regex_module.compile(r"^([1-91-9]\p{Number}*)[ .-](?:[" + _UNICODE_WHITESPACE_CLASS + r"]|\p{Lu})") +# Equation separator fallback. This intentionally matches only the literal +# string pattern around ``p{Number}``, so the branch remains inert for ordinary +# numeric text. +DEAD_DIGIT_RE = re.compile(r"^.p\{Number\}+.$") + +# Trie of equation-like keywords ("equation", "eqn", "eq", plus multilingual +# variants). +EQUATION_KEYWORDS_TRIE = build_trie( + [ + "equation", "equation.", "eqn", "eqn.", "eq", "eq.", + "ecuación", "equação", "gleichung", "equazione", "ekvation", + "yhtälö", "ligning", "persamaan", "denklem", "ecuația", + "equació", "rovnica", "rovnice", "równanie", "vergelijking", + "jednadžba", "jöfnu", "võrrand", "vienādojums", "lygtis", + "enačba", "egyenlet", "phương trình", "εξίσωση", + "方程", "방정식", "уравнение", "рівняння", "раўнанне", "једначина", + ], + set_case_fold(TrieConfig(), True), +) + + +# Roman and English number words used by heading numbering detectors. +ENGLISH_WORD_TO_NUMBER = { + "one": 1, "two": 2, "three": 3, "four": 4, "five": 5, "six": 6, + "seven": 7, "eight": 8, "nine": 9, "ten": 10, "eleven": 11, + "twelve": 12, "thirteen": 13, "fourteen": 14, "fifteen": 15, + "sixteen": 16, "seventeen": 17, "eighteen": 18, "nineteen": 19, "twenty": 20, +} +ROMAN_NUMERAL_MAP = { + "I": 1, "II": 2, "III": 3, "IV": 4, "V": 5, "VI": 6, "VII": 7, + "VIII": 8, "IX": 9, "X": 10, "XI": 11, "XII": 12, "XIII": 13, + "XIV": 14, "XV": 15, "XVI": 16, "XVII": 17, "XVIII": 18, "XIX": 19, "XX": 20, +} + + +# Special-character weights used by equation-content scoring. +FORMULA_CHAR_WEIGHTS = { + "=": 10, "{": 5, "}": 5, "+": 5, "/": 3, "*": 3, + "-": 1, "~": 1, "[": 1, "]": 1, "(": 1, ")": 1, +} diff --git a/pageindex/flash/heading_detection/neighbors.py b/pageindex/flash/heading_detection/neighbors.py new file mode 100644 index 000000000..55e461332 --- /dev/null +++ b/pageindex/flash/heading_detection/neighbors.py @@ -0,0 +1,151 @@ +"""Per-page block neighborhood maps and neighbor lookups.""" + +from __future__ import annotations + +import math +from typing import Any, Optional +from ..model import ( + _UNICODE_WHITESPACE_CLASS, + _strip_diacritics, + _trim_unicode_ws, + style_key, magnitude_ratio, same_x_extent, same_y_extent, y_overlaps, left_aligned, right_aligned, center_aligned, x_aligned, x_centers_close, to_number, + last_span, avg_char_width, raw_text_of_line, heading_score, numbering_text, numbering_value, numbering_kind, Line, last_line_of, first_span_of, is_word_category, block_text, is_punct_category, deaccented_text, letter_count, punct_count, dominant_style_of, + info_weight, dominant_font_size, is_upper_dominant, is_caps_heavy, CharStats, alignment_code, Block, +) + +from .text_checks import clamp + + +# --------------------------------------------------------------------------- # +# Per-page neighbor map. +# --------------------------------------------------------------------------- # + + +class BlockNeighborCache: + """Per-block neighbor cache populated by the page neighbor map.""" + + __slots__ = ("state_slot", "tertiary_slot", "measure_slot", "auxiliary_slot", "primary_slot", "secondary_slot", "option_slot") + + def __init__(self): + self.state_slot = False # initialized flag + self.tertiary_slot = None # closest body block below + self.measure_slot = None # block 1-column-left + self.auxiliary_slot = None # next block to the right + self.primary_slot = None # earlier body block above + self.secondary_slot = None # nearest body block above + self.option_slot = None # block 1-column-right peer + + +def compute_bucket_span(neighbor_map, block) -> dict: + """Compute the inclusive horizontal bucket span for a block.""" + start_bucket = int(clamp(math.floor(block.left_edge() / neighbor_map.tertiary_slot), 0, neighbor_map.secondary_slot - 1)) + end_bucket = int(clamp(math.ceil(block.right_edge() / neighbor_map.tertiary_slot), 0, neighbor_map.secondary_slot - 1)) + return {"start_bucket": start_bucket, "end_bucket": end_bucket} + + +def neighbor_above(neighbor_map, other_block: Block) -> Optional[Block]: + """closest 'j' neighbor (block above).""" + width_value = neighbor_map.primary_slot[other_block.orig_index] if other_block.orig_index < len(neighbor_map.primary_slot) else None + return width_value.tertiary_slot if width_value is not None else None + + +def body_neighbor_above(neighbor_map, other_block: Block) -> Optional[Block]: + """closest 'g' neighbor.""" + width_value = neighbor_map.primary_slot[other_block.orig_index] if other_block.orig_index < len(neighbor_map.primary_slot) else None + return width_value.primary_slot if width_value is not None else None + + +def neighbor_right(neighbor_map, other_block: Block) -> Optional[Block]: + """Closest right-side peer neighbor.""" + width_value = neighbor_map.primary_slot[other_block.orig_index] if other_block.orig_index < len(neighbor_map.primary_slot) else None + return width_value.auxiliary_slot if width_value is not None else None + + +def neighbor_right_peer(neighbor_map, secondary_item): + return neighbor_right(neighbor_map, secondary_item) + + +def closest_body_neighbor_above(neighbor_map, other_block: Block) -> Optional[Block]: + """Closest stored neighbor above.""" + width_value = neighbor_map.primary_slot[other_block.orig_index] if other_block.orig_index < len(neighbor_map.primary_slot) else None + return width_value.secondary_slot if width_value is not None else None + + +class PageNeighborMap: + """Per-page horizontal-bucket neighbor map for constant-time nearby-block queries.""" + + __slots__ = ("tertiary_slot", "secondary_slot", "primary_slot") + + def __init__(self, page): + blocks = page.output_slot + self.tertiary_slot = max(5, page.bounds.bbox_width() / 300) # bucket width + self.secondary_slot = int(math.floor(page.bounds.bbox_width() / self.tertiary_slot)) # bucket count + self.primary_slot: list[Optional[BlockNeighborCache]] = [None] * (max(len(blocks), 1) + 1) + # mark buckets crossed by body-marked blocks + marked = [False] * self.secondary_slot + for candidate_item in blocks: + if not candidate_item.is_body_paragraph: + continue + spans = compute_bucket_span(self, candidate_item) + for index in range(spans["start_bucket"], spans["end_bucket"]): + if 0 <= index < self.secondary_slot: + marked[index] = True + recent_height: list[int] = [-1] * self.secondary_slot # most recent body block height at bucket + recent_block_index: list[int] = [-1] * self.secondary_slot # most-recent block V (j-direction) + recent: list[Optional[Block]] = [None] * self.secondary_slot # most-recent block at bucket + pending: list[list[int]] = [[] for _ in range(self.secondary_slot)] # pending V's per bucket + + for block_index, current_block in enumerate(blocks): + if ( + current_block.char_count() <= 0 + or current_block.skew_frac() > 1 + or current_block.type in (1, 2, 12) + ): + continue + width = BlockNeighborCache() + self.primary_slot[current_block.orig_index] = width + spans = compute_bucket_span(self, current_block) + left = spans["start_bucket"] + right = spans["end_bucket"] + # H field: block 1-column-left or right + value = recent_block_index[left] + adjacent_bucket_index = recent_block_index[ + left - 1 if (left > 0 and current_block.left_edge() < (left + 0.5) * self.tertiary_slot) + else (left + 1 if left < self.secondary_slot - 1 else left) + ] + if value >= 0 or adjacent_bucket_index >= 0: + same_bucket_block = blocks[value] if 0 <= value < len(blocks) else None + adjacent_bucket_block = blocks[adjacent_bucket_index] if 0 <= adjacent_bucket_index < len(blocks) else None + if same_bucket_block is not None and (adjacent_bucket_block is None or same_bucket_block.bottom_edge() < adjacent_bucket_block.bottom_edge()): + picked_index = value + else: + picked_index = adjacent_bucket_index + if 0 <= picked_index < len(blocks): + width.measure_slot = blocks[picked_index] + if self.primary_slot[picked_index] is not None: + self.primary_slot[picked_index].option_slot = current_block + recent_block_index[left] = current_block.orig_index + for col in range(left, right): + if 0 <= col < self.secondary_slot: + width.state_slot = width.state_slot or marked[col] + recent_block = recent[col] + if recent_block is not None and (width.primary_slot is None or recent_block.bottom_edge() < width.primary_slot.bottom_edge()): + width.primary_slot = recent_block + previous_body_index = recent_height[col] + recent_height[col] = current_block.orig_index + if previous_body_index >= 0 and previous_body_index < len(blocks): + same_bucket_block = blocks[previous_body_index] + if width.tertiary_slot is None or same_bucket_block.bottom_edge() < width.tertiary_slot.bottom_edge(): + width.tertiary_slot = same_bucket_block + previous_cache = self.primary_slot[previous_body_index] + if previous_cache is not None and (previous_cache.auxiliary_slot is None or current_block.top_edge() > previous_cache.auxiliary_slot.top_edge()): + previous_cache.auxiliary_slot = current_block + if current_block.is_body_paragraph: + for pending_index in pending[col]: + if 0 <= pending_index < len(self.primary_slot): + pending_neighbor = self.primary_slot[pending_index] + if pending_neighbor is not None and (pending_neighbor.secondary_slot is None or current_block.top_edge() > pending_neighbor.secondary_slot.top_edge()): + pending_neighbor.secondary_slot = current_block + recent[col] = current_block + pending[col].clear() + pending[col].append(current_block.orig_index) diff --git a/pageindex/flash/heading_detection/page_scan.py b/pageindex/flash/heading_detection/page_scan.py new file mode 100644 index 000000000..d6ebe6d8e --- /dev/null +++ b/pageindex/flash/heading_detection/page_scan.py @@ -0,0 +1,474 @@ +"""Whole-page heading scan and document-level candidate collection/filtering.""" + +from __future__ import annotations + +import math +from typing import Any, Optional +from ..outline_assembly import HeadingCandidate, OutlineNode +from ..labels import is_uppercase_dominant, trie_matches_all, advance_past_line, skip_bracketed_word, token_case_signal, format_caption_label, CaptionEntry, extract_structural_number +from ..model import ( + _UNICODE_WHITESPACE_CLASS, + _strip_diacritics, + _trim_unicode_ws, + style_key, magnitude_ratio, same_x_extent, same_y_extent, y_overlaps, left_aligned, right_aligned, center_aligned, x_aligned, x_centers_close, to_number, + last_span, avg_char_width, raw_text_of_line, heading_score, numbering_text, numbering_value, numbering_kind, Line, last_line_of, first_span_of, is_word_category, block_text, is_punct_category, deaccented_text, letter_count, punct_count, dominant_style_of, + info_weight, dominant_font_size, is_upper_dominant, is_caps_heavy, CharStats, alignment_code, Block, +) +from ..tokens import ( + is_trimmable_token, token_numeric_value, Token, TokenView, wrap_tokens, enumerate_tokens, last_token, trie_prefix_match, strip_trie_match, strip_leading_if_in, COMMA_CHARS, strip_trailing_comma, first_token, trim_trailing_punct, set_case_fold, TrieConfig, build_trie, tokenize_block, + trie_full_match, last_token_anchor, first_anchor_span, is_char_token, is_word_token, +) + +from .keyword_tables import ( + SECTION_KEYWORDS_TRIE, + INTRODUCTION_SECTION_TRIE, +) +from .text_checks import ( + is_heading_continuation, + matches_abstract, + matches_references, + has_substantive_content, + is_cover_page, +) +from .neighbors import ( + neighbor_above, + neighbor_right, + closest_body_neighbor_above, +) +from .candidates import ( + PageScanState, + push_candidate, + make_plain_candidate, + make_body_heading_candidate, +) +from .detectors import ( + detect_numbered_heading, + detect_labeled_heading, + detect_chapter_appendix, + try_classify_heading, + passes_neighbor_check, + has_competing_labeled_heading, +) +from .style_detectors import ( + detect_font_heading, + detect_heading_with_body, +) + + +# --------------------------------------------------------------------------- # +# Main per-page heading scan # +# --------------------------------------------------------------------------- # + + +def scan_page_headings(page_scan: PageScanState) -> list[HeadingCandidate]: + """Return heading candidates found on this page.""" + if is_cover_page(page_scan.secondary_slot, page_scan.primary_slot): + return [] + page_scan.option_slot.clear() + page_scan.measure_slot.clear() + blocks = page_scan.auxiliary_slot + for block in blocks: + if block.char_count() <= 0 or block.skew_frac() > 1 or block.type != 0: + continue + if block.state_slot != 0: # already classified + continue + above = neighbor_above(page_scan.tertiary_slot, block) + if above is not None and above.secondary_slot.contains(block.secondary_slot): + continue + if block.char_count() <= 1 and block.char_stats.secondary_slot != 4: + continue + wn_entry = page_scan.tertiary_slot.primary_slot[block.orig_index] if 0 <= block.orig_index < len(page_scan.tertiary_slot.primary_slot) else None + has_da_above = wn_entry is not None and wn_entry.state_slot + rows = block.line_count() + # Try lo for 2-line heading-body patterns + if has_da_above and rows > 1 and ( + (0 < block.bold_frac() < 1) + or style_key(first_span_of(block)) != style_key(last_span(last_line_of(block))) + ): + lo_result = detect_heading_with_body(page_scan, block) + if lo_result is not None: + push_candidate(page_scan, lo_result) + continue + tokens = tokenize_block(block) + first_line = block.line() + first_line_tokens = tokens.slice(0, advance_past_line(tokens, first_line, 0)) + if has_da_above and not block.measure_slot and rows >= 3\ + and first_line.bbox_width() <= 0.2 * min(block.primary_slot[1].bbox_width(), block.primary_slot[2].bbox_width())\ + and matches_abstract(first_line_tokens): + push_candidate(page_scan, make_body_heading_candidate(page_scan, 5, block, first_line_tokens)) + continue + if block.char_count() >= 200: + continue + if block.char_count() >= 100 and rows > 1 and block.char_stats.primary_slot[6] - first_line.char_stats.primary_slot[6] > 1: + continue + size = block.avg_font_size() + page_width = page_scan.primary_slot.bounds.bbox_width() + lots_caps = block.char_stats.primary_slot[2] >= max(3, letter_count(block.char_stats) / 2) + if rows > 4 or (rows >= 3 and not (size >= 1.5 * page_scan.primary_slot.primary_slot.primary_slot or lots_caps)): + continue + if block.weighted_ratio_primary < 0.5 * page_scan.secondary_slot.secondary_slot.auxiliary_slot: + continue + if letter_count(block.char_stats) <= 0: + continue + if block.bold_frac() < 0.1 and not lots_caps and size < page_scan.secondary_slot.secondary_slot.primary_slot - 2: + continue + layout_gate = detect_chapter_appendix(page_scan, block) + if layout_gate is not None: + push_candidate(page_scan, layout_gate) + continue + if passes_neighbor_check(page_scan, block): + continue + top_gap = above.bottom_edge() - block.top_edge() if above is not None else math.inf + isolated = ( + not block.measure_slot and rows <= 2 + and (above is None or top_gap > 1.5 * block.avg_font_size() + or (above.state_slot != 5 and above.state_slot != 11)) + ) + if isolated: + if matches_references(tokens): + push_candidate(page_scan, make_plain_candidate(page_scan, 7, block)) + continue + if has_da_above and trie_matches_all(INTRODUCTION_SECTION_TRIE, tokens): + push_candidate(page_scan, make_plain_candidate(page_scan, 11, block)) + continue + above_index = block.orig_index - 1 + below_index = block.orig_index + 1 + previous_block = blocks[above_index] if 0 <= above_index < len(blocks) else None + below = blocks[below_index] if 0 <= below_index < len(blocks) else None + if not has_da_above and ( + not (heading_score(block) >= page_scan.primary_slot.primary_slot.primary_slot + 1.5) + or (above is not None and above.type != 1) + or (previous_block is not None and previous_block.type != 1) + or (below is not None and not (below.top_edge() < block.bottom_edge() - size)) + ): + continue + if block.bold_frac() < 0.1 and not lots_caps\ + and first_span_of(block).font_name == page_scan.primary_slot.primary_slot.state_slot\ + and size < page_scan.secondary_slot.secondary_slot.primary_slot - 0.5: + continue + predecessor = neighbor_right(page_scan.tertiary_slot, block) + if predecessor is not None and predecessor.skew_frac() > 1: + continue + if top_gap < 0: + continue + predecessor_gap = block.bottom_edge() - predecessor.top_edge() if predecessor is not None else math.inf + if predecessor_gap < -0.9 * block.bbox_height(): + continue + line_gap = page_scan.primary_slot.primary_slot.tertiary_slot - page_scan.primary_slot.primary_slot.primary_slot + if top_gap < line_gap and size < page_scan.primary_slot.primary_slot.primary_slot - 1: + continue + # Sibling/peer block pointers from the neighbor cache. + right_neighbor_sib = wn_entry.measure_slot if wn_entry is not None else None + left_neighbor_sib = wn_entry.option_slot if wn_entry is not None else None + + heading_kind = detect_numbered_heading(page_scan, block, tokens) + if heading_kind is not None: + from ..stats import column_index_of as _column_index + col_idx = _column_index(block) if block.primary_slot else 0 + column_rect = page_scan.primary_slot.tertiary_slot[col_idx] if 0 <= col_idx < len(page_scan.primary_slot.tertiary_slot) else None + # Narrow-column heading-vs-prev-numbering check + if (size < page_scan.secondary_slot.secondary_slot.primary_slot - 1 + and block.bbox_width() < 0.2 * page_width + and column_rect is not None + and column_rect.bbox_width() < 0.2 * page_width + and column_rect.bbox_height() > 1.5 * column_rect.bbox_width()): + first_number = heading_kind.numbering[0] if heading_kind.numbering else 0 + if above is not None: + first = first_token(tokenize_block(above)) + if first is not None and first.type == 1 and token_numeric_value(first) != first_number: + continue + if predecessor is not None: + predecessor_first_token = first_token(tokenize_block(predecessor)) + if predecessor_first_token is not None and predecessor_first_token.type == 1 and token_numeric_value(predecessor_first_token) != first_number: + continue + # Prev-block continuation check via Kn (numbered-sequence test) + first_token_value = tokens.token_at(0) + second_token_value = tokens.token_at(1) if len(tokens) > 1 else None + if len(heading_kind.numbering) <= 1 and ( + (first_token_value is not None and first_token_value.boundary_slot) + or (second_token_value is not None and is_word_token(second_token_value))): + first_number = heading_kind.numbering[0] if heading_kind.numbering else 0 + if above is not None and is_heading_continuation(above, block, first_number): + continue + if (right_neighbor_sib is not None and above is not right_neighbor_sib + and not center_aligned(block, right_neighbor_sib, 1) + and (above.line_count() < 10 or above.char_count() < 300) + and is_heading_continuation(right_neighbor_sib, block, first_number)): + continue + if predecessor is not None and is_heading_continuation(predecessor, block, first_number): + continue + if (left_neighbor_sib is not None and predecessor is not left_neighbor_sib + and not center_aligned(block, left_neighbor_sib, 1) + and (predecessor.line_count() < 10 or predecessor.char_count() < 300) + and is_heading_continuation(left_neighbor_sib, block, first_number)): + continue + # Top-of-page small-font footnote-marker rejection + second = tokens.token_at(1) if len(tokens) > 1 else None + if (block.top_edge() < page_scan.primary_slot.bounds.bbox_height() / 4 + and size <= page_scan.primary_slot.primary_slot.primary_slot + and first_span_of(block).char_stats.secondary_slot == 1 + and first_span_of(block).bbox_height() < size - 0.5 + and len(heading_kind.numbering) <= 1 + and second is not None and is_char_token(second)): + continue + push_candidate(page_scan, heading_kind) + continue + if block.measure_slot: + continue + if block.char_count() >= 120: + continue + if top_gap <= line_gap - 0.1: + continue + heading_signature = detect_labeled_heading(page_scan, block, tokens) + if heading_signature is not None: + if above is not None and has_competing_labeled_heading(page_scan, heading_signature, above): + continue + if (right_neighbor_sib is not None and above is not right_neighbor_sib + and has_competing_labeled_heading(page_scan, heading_signature, right_neighbor_sib)): + continue + if predecessor is not None and has_competing_labeled_heading(page_scan, heading_signature, predecessor): + continue + if (left_neighbor_sib is not None and predecessor is not left_neighbor_sib + and has_competing_labeled_heading(page_scan, heading_signature, left_neighbor_sib)): + continue + push_candidate(page_scan, heading_signature) + continue + if isolated: + caps_heavy = size + 2 * block.bold_frac() >= page_scan.secondary_slot.secondary_slot.primary_slot + 4 or is_upper_dominant(block.char_stats) + above_or_overlap = closest_body_neighbor_above(page_scan.tertiary_slot, block) + page_width = page_scan.primary_slot.bounds.bbox_width() + # Abstract-heading acceptance uses the closest above-overlap block + # as the guard; when it exists, the predecessor exists too. + type5_cond = caps_heavy or ( + above_or_overlap is not None + and (block.bottom_edge() - above_or_overlap.top_edge() < 3 * (block.bottom_edge() - predecessor.top_edge()) + or info_weight(predecessor.char_stats) >= 30) + ) + if type5_cond and matches_abstract(tokens): + push_candidate(page_scan, make_plain_candidate(page_scan, 5, block)) + continue + type6_cond = ( + caps_heavy + or (above is not None and x_aligned(block, above, 1) + and (above.bbox_width() >= page_width / 6 + or above.bold_frac() > 0.9 + or is_upper_dominant(above.char_stats))) + or (predecessor is not None and x_aligned(block, predecessor, 1) + and (predecessor.bbox_width() >= page_width / 6 + or predecessor.bold_frac() > 0.9 + or is_upper_dominant(predecessor.char_stats) + or (block.previous_slot < 0.1 and predecessor.previous_slot > 0.9))) + ) + if type6_cond and trie_full_match(SECTION_KEYWORDS_TRIE, tokens): + push_candidate(page_scan, make_plain_candidate(page_scan, 6, block)) + continue + if info_weight(block.char_stats) <= 3: + continue + if has_substantive_content(block, previous_block, below): + continue + outline_context = detect_font_heading(page_scan, block) + if outline_context is not None: + push_candidate(page_scan, outline_context) + return page_scan.option_slot + + +# --------------------------------------------------------------------------- # +# Document-wide outline-candidate collector # +# --------------------------------------------------------------------------- # + + +class DocCandidateCollector: + """Document-level state aggregating per-page heading candidates.""" + + __slots__ = ("previous_slot", "measure_slot", "option_slot", "auxiliary_slot", "primary_slot", "tertiary_slot", "secondary_slot", "state_slot") + + def __init__(self, doc, labeled): + self.previous_slot = doc + self.measure_slot = labeled + self.option_slot = 0 + self.auxiliary_slot = False + self.primary_slot = 0 + self.secondary_slot = False + self.tertiary_slot = False + self.state_slot: list[HeadingCandidate] = [] + + +def filter_page_candidates(doc_collector: DocCandidateCollector, page, page_candidates: list[HeadingCandidate]) -> None: + """Per-page candidate filter for noisy pages, title overlap, page headers, and numbering continuity.""" + from ..outline_assembly import is_script_compatible + from ..model import intervals_overlap, is_caps_heavy + from ..stats import column_index_of + + # Advance document-level numbering state through outline entries up to this page. + while doc_collector.option_slot < len(doc_collector.measure_slot): + outline_entry = doc_collector.measure_slot[doc_collector.option_slot] + current_candidate = outline_entry.heading + if current_candidate.page.page_index > page.page_index: + break + if current_candidate.type == 2: + if not doc_collector.tertiary_slot: + doc_collector.tertiary_slot = (len(current_candidate.numbering) > 0 and current_candidate.numbering[0] == 1) + elif current_candidate.type == 4: + if not doc_collector.secondary_slot: + doc_collector.secondary_slot = (len(current_candidate.numbering) > 0 and current_candidate.numbering[0] == 1) + elif current_candidate.type == 1 and len(current_candidate.numbering) > 0: + doc_collector.primary_slot = max(doc_collector.primary_slot, current_candidate.numbering[0]) + doc_collector.option_slot += 1 + + count = len(page_candidates) + if count >= 20: + return + + # Sort candidates by column, vertical position, then horizontal position. + def _ih_key(heading_candidate: HeadingCandidate): + first_line = heading_candidate.group_slot.primary_slot[0] if heading_candidate.group_slot.primary_slot else None + column_index = first_line.measure_slot if first_line is not None else -1 + return (column_index, -heading_candidate.group_slot.top_edge(), -heading_candidate.group_slot.bottom_edge(), heading_candidate.group_slot.left_edge(), heading_candidate.group_slot.right_edge()) + page_candidates.sort(key=_ih_key) + + accepted: list[HeadingCandidate] = [] + title: Optional[Block] = None + if not doc_collector.auxiliary_slot and getattr(page, "auxiliary_slot", False): + for block in page.output_slot: + if block.type == 3: + title = block + break + + min_first_number = math.inf + total_bottom = math.inf + single_numbering_count = 0 + for page_candidate in page_candidates: + if total_bottom == math.inf and page_candidate.type == 5: + total_bottom = page_candidate.group_slot.top_edge() + page_candidate.group_slot.avg_font_size() + if page_candidate.type == 1 and len(page_candidate.numbering) > 0: + min_first_number = min(min_first_number, page_candidate.numbering[0]) + if len(page_candidate.numbering) == 1: + single_numbering_count += 1 + + max_first_number = 0 + for index in range(count): + active_candidate = page_candidates[index] + next_item = page_candidates[index + 1] if index + 1 < count else None + + if is_script_compatible(doc_collector.previous_slot.secondary_slot.tertiary_slot, active_candidate): + continue + + if not doc_collector.auxiliary_slot and active_candidate.type != 11: + bottom = active_candidate.group_slot.top_edge() + if (title is not None and bottom > title.top_edge() + and intervals_overlap(active_candidate.group_slot.left_edge(), active_candidate.group_slot.right_edge(), title.left_edge(), title.right_edge())): + continue + if bottom > total_bottom: + continue + + if (active_candidate.type == 0 and next_item is not None and next_item.type == 5 + and active_candidate.group_slot.left_edge() <= next_item.group_slot.right_edge() and active_candidate.group_slot.right_edge() >= next_item.group_slot.left_edge() + and active_candidate.group_slot.bottom_edge() - next_item.group_slot.top_edge() < 2 * active_candidate.group_slot.bbox_height() + and len(tokenize_block(active_candidate.group_slot)) > 1): + continue + + if active_candidate.type == 2: + if len(active_candidate.numbering) > 0 and active_candidate.numbering[0] == 1: + doc_collector.tertiary_slot = True + elif not doc_collector.tertiary_slot: + continue + accepted.append(active_candidate) + continue + + if active_candidate.type == 4: + if len(active_candidate.numbering) > 0 and active_candidate.numbering[0] == 1: + doc_collector.secondary_slot = True + elif not doc_collector.secondary_slot: + continue + accepted.append(active_candidate) + continue + + if active_candidate.type != 1: + accepted.append(active_candidate) + continue + + # type == 1 + if single_numbering_count >= 5: + continue + first_number = active_candidate.numbering[0] if len(active_candidate.numbering) > 0 else 0 + if (active_candidate.group_slot.bold_frac() < 0.9 and not is_caps_heavy(active_candidate.group_slot) + and active_candidate.group_slot.avg_font_size() < page.primary_slot.primary_slot + 1): + if doc_collector.primary_slot <= 0 and min_first_number > 1 and len(active_candidate.numbering) <= 1: + continue + if first_number > 3 * page.page_index: + continue + max_first_number = max(max_first_number, first_number) + accepted.append(active_candidate) + + doc_collector.state_slot.extend(accepted) + doc_collector.auxiliary_slot = True + doc_collector.primary_slot = max(doc_collector.primary_slot, max_first_number) + + +# --------------------------------------------------------------------------- # +# Public entry point: build heading candidates for the whole document # +# --------------------------------------------------------------------------- # + + +def build_doc_heading_candidates(doc, labeled: Optional[list] = None) -> list[HeadingCandidate]: + """Run per-page heading detection across the document.""" + doc_collector = DocCandidateCollector(doc, labeled if labeled is not None else []) + saw_body = False + for page in doc.primary_slot: + # Skip initial cover-like pages until the first body-like page is reached. + from ..title import is_cover_like_page + if not saw_body and is_cover_like_page(doc, page): + continue + saw_body = True + page_vo = PageScanState(doc, page) + page_candidates = scan_page_headings(page_vo) + filter_page_candidates(doc_collector, page, page_candidates) + return doc_collector.state_slot + + +def find_section_openers(doc, start_page_idx: int) -> list: + """Find the first valid heading on each page, then clique-filter the result.""" + from ..outline_assembly import is_script_compatible, has_conflict_in_context, OutlineContext, OutlineNode + + item_list: list[HeadingCandidate] = [] + index = start_page_idx + while index < len(doc.primary_slot): + page = doc.primary_slot[index] + current_candidate: Optional[HeadingCandidate] = None + page_scan_state = PageScanState(doc, page) + if not page_scan_state.primary_slot.auxiliary_slot: + for block in page_scan_state.auxiliary_slot: + if block.char_count() <= 0 or block.skew_frac() > 1 or block.type != 0: + continue + if block.is_body_paragraph or block.top_edge() < 0.5 * page_scan_state.primary_slot.bounds.bbox_height(): + break + if block.marker_slot != 0: + break + detected_candidate = try_classify_heading(page_scan_state, block) + if detected_candidate is not None: + current_candidate = detected_candidate + break + if block.line_count() > 2: + break + if current_candidate is not None and not is_script_compatible(doc.secondary_slot.tertiary_slot, current_candidate): + item_list.append(current_candidate) + index += 1 + + if len(item_list) <= 1: + return [] + + # Compare candidates against the full context and against the accepted subset. + bundle = OutlineContext(item_list) + accepted_context = OutlineContext([]) + out = [] + for current_candidate in item_list: + if accepted_context.has_nearby_duplicate(current_candidate): + current_candidate.group_slot.type = 12 + continue + if has_conflict_in_context(bundle, current_candidate): + continue + accepted_context.add(current_candidate) + out.append(OutlineNode(current_candidate)) + current_candidate.group_slot.type = 7 + current_candidate.group_slot.used_as_heading = True + return out diff --git a/pageindex/flash/heading_detection/style_detectors.py b/pageindex/flash/heading_detection/style_detectors.py new file mode 100644 index 000000000..bed5454e9 --- /dev/null +++ b/pageindex/flash/heading_detection/style_detectors.py @@ -0,0 +1,383 @@ +"""Font-change and body-embedded heading detectors.""" + +from __future__ import annotations + +import math +from typing import Any, Optional +from ..outline_assembly import HeadingCandidate, OutlineNode +from ..labels import is_uppercase_dominant, trie_matches_all, advance_past_line, skip_bracketed_word, token_case_signal, format_caption_label, CaptionEntry, extract_structural_number +from ..model import ( + _UNICODE_WHITESPACE_CLASS, + _strip_diacritics, + _trim_unicode_ws, + style_key, magnitude_ratio, same_x_extent, same_y_extent, y_overlaps, left_aligned, right_aligned, center_aligned, x_aligned, x_centers_close, to_number, + last_span, avg_char_width, raw_text_of_line, heading_score, numbering_text, numbering_value, numbering_kind, Line, last_line_of, first_span_of, is_word_category, block_text, is_punct_category, deaccented_text, letter_count, punct_count, dominant_style_of, + info_weight, dominant_font_size, is_upper_dominant, is_caps_heavy, CharStats, alignment_code, Block, +) +from ..tokens import ( + is_trimmable_token, token_numeric_value, Token, TokenView, wrap_tokens, enumerate_tokens, last_token, trie_prefix_match, strip_trie_match, strip_leading_if_in, COMMA_CHARS, strip_trailing_comma, first_token, trim_trailing_punct, set_case_fold, TrieConfig, build_trie, tokenize_block, + trie_full_match, last_token_anchor, first_anchor_span, is_char_token, is_word_token, +) + +from .keyword_tables import ( + SECTION_KEYWORDS_TRIE, + INTRODUCTION_SECTION_TRIE, + KEYWORDS_SECTION_TRIE, +) +from .text_checks import ( + matches_abstract, + vertically_close, +) +from .neighbors import ( + neighbor_above, + body_neighbor_above, + neighbor_right, + closest_body_neighbor_above, +) +from .candidates import ( + PageScanState, + make_heading_candidate, + make_plain_candidate, + make_body_heading_candidate, +) +from .detectors import ( + detect_numbered_heading, + detect_labeled_heading, + is_bibliography_entry, +) + + +def detect_font_heading(page_scan: PageScanState, other_block: Block) -> Optional[HeadingCandidate]: + """Detailed font/position-based fallback heading classifier.""" + from ..model import x_aligned, last_span, last_line_of, first_span_of, letter_count, punct_count, dominant_style_of, is_upper_dominant, is_caps_heavy, is_sentence_like, alignment_code + from ..tokens import last_token, is_comma_token + + above = neighbor_above(page_scan.tertiary_slot, other_block) + top_gap = above.bottom_edge() - other_block.top_edge() if above is not None else math.inf + predecessor = neighbor_right(page_scan.tertiary_slot, other_block) + predecessor_gap = other_block.bottom_edge() - predecessor.top_edge() if predecessor is not None else math.inf + keyword_match = body_neighbor_above(page_scan.tertiary_slot, other_block) + above_or_overlap = closest_body_neighbor_above(page_scan.tertiary_slot, other_block) + + # Initial gate: one of On OR bold/centered tall block. + if not ( + vertically_close(keyword_match, other_block) or vertically_close(above_or_overlap, other_block) + or (other_block.bottom_edge() >= 0.8 * page_scan.primary_slot.bounds.bbox_height() + and other_block.avg_font_size() >= page_scan.secondary_slot.secondary_slot.primary_slot + 1 + and other_block.bold_frac() > 0.9 + and (above is None or above.type == 1)) + ): + return None + + page = page_scan.primary_slot.primary_slot # page statistics + far = 10 * min(other_block.avg_font_size(), page.tertiary_slot) + if top_gap < math.inf and top_gap > far and above.state_slot == 0: + return None + if predecessor is not None and predecessor.state_slot != 0: + return None + + # Compound rejection for candidates sitting above non-body predecessors. + # Keep the explicit short-circuit structure: each inner predicate requires + # the predecessor to exist. + + inner_reject = False + if above is not None and above.state_slot != 0: + inner_reject = ( + predecessor_gap > 5 * page.tertiary_slot + or (predecessor is not None and not predecessor.is_body_paragraph) + or (predecessor is not None and predecessor.bbox_width() < page_scan.primary_slot.bounds.bbox_width() / 5) + or (predecessor is not None and predecessor.char_count() < 0.5 * other_block.char_count()) + or (predecessor is not None and predecessor.weighted_ratio_secondary < 0.33) + or (predecessor is not None and predecessor.char_count() < 500 + and predecessor.weighted_ratio_secondary < 0.5 and alignment_code(predecessor) != 1) + or (predecessor is not None and predecessor.char_count() < 250 and predecessor.weighted_ratio_secondary < 0.5) + ) + if (inner_reject + or (predecessor is not None and ( + predecessor.weighted_ratio_primary < 0.67 * page_scan.secondary_slot.secondary_slot.auxiliary_slot + or (other_block.char_count() < 30 and predecessor.char_count() < 300 + and predecessor.weighted_ratio_primary < 0.8 * page_scan.secondary_slot.secondary_slot.auxiliary_slot)))): + return None + last_tok = last_token(tokenize_block(other_block)) + if last_tok is not None and is_comma_token(last_tok): + return None + + # Branch 1: tall first-line + big-font heading + if (predecessor_gap < math.inf and predecessor_gap > 0 + and other_block.style_slot >= page_scan.secondary_slot.secondary_slot.primary_slot + 2 + and other_block.avg_font_size() >= page_scan.secondary_slot.secondary_slot.primary_slot + 1.5 + and other_block.avg_font_size() >= page.primary_slot + 0.5 + and (above is None or (other_block.style_slot >= above.style_slot and other_block.avg_font_size() >= above.avg_font_size())) + and predecessor is not None + and other_block.style_slot >= predecessor.style_slot and other_block.avg_font_size() >= predecessor.avg_font_size()): + return make_plain_candidate(page_scan, 0, other_block) + + caps_heavy = is_caps_heavy(other_block) + # Branch 2 reject: matches body-style and not all-caps, OR clearly + # smaller font than predecessor near it. + if ((dominant_style_of(other_block) in page_scan.primary_slot.style_slot and not caps_heavy + and (page.auxiliary_slot == dominant_style_of(other_block) + or (other_block.bold_frac() < 0.9 and other_block.previous_slot < 0.9 + and alignment_code(other_block) != 3 and not is_sentence_like(other_block)))) + or (above is not None and predecessor is not None + and other_block.avg_font_size() <= predecessor.avg_font_size() + and top_gap < predecessor_gap / 4)): + return None + + # Branch 3: medium-confidence font-size heading + if (predecessor_gap < math.inf and predecessor_gap > 0 + and other_block.avg_font_size() >= page_scan.secondary_slot.secondary_slot.primary_slot + 0.5 + and predecessor is not None + and other_block.style_slot >= predecessor.style_slot and other_block.avg_font_size() >= predecessor.avg_font_size() + and predecessor.avg_font_size() >= page.primary_slot - 0.5 + and other_block.bbox_width() < 0.95 * predecessor.bbox_width() + and predecessor.bbox_width() >= 0.25 * page_scan.primary_slot.bounds.bbox_width()): + return make_plain_candidate(page_scan, 0, other_block) + + line_height = page.tertiary_slot - page.primary_slot + # Branch 4: moderate-gap large-font heading + if (predecessor_gap > line_height and predecessor_gap < 5 * line_height + and (above is None or other_block.avg_font_size() >= above.avg_font_size() + 0.5) + and predecessor is not None + and other_block.avg_font_size() >= predecessor.avg_font_size() + 0.5 + and other_block.avg_font_size() >= page_scan.secondary_slot.secondary_slot.primary_slot - 0.5 + and other_block.bbox_width() < 0.95 * predecessor.bbox_width() + and predecessor.is_body_paragraph + and predecessor.avg_font_size() >= page.primary_slot - 0.5 + and predecessor.char_stats.secondary_slot != 1): + return make_plain_candidate(page_scan, 0, other_block) + + # Reject: many letters with low density signals body para + letters = other_block.char_stats.primary_slot[6] + if other_block.char_stats.primary_slot[10] != 0: + ratio = (letters + other_block.char_stats.primary_slot[8]) / other_block.char_stats.primary_slot[10] + else: + # IEEE division edge case: positive numerator over zero behaves as +inf, + # which keeps the low-density rejection active. + ratio = math.inf if (letters + other_block.char_stats.primary_slot[8]) > 0 else math.nan + if letters > 1 and ratio > 0.3: + return None + + symbol_count = punct_count(other_block.char_stats) + letter_total = letter_count(other_block.char_stats) + # Same IEEE division edge case as the letter-density ratio above. + symbol_ratio = symbol_count / letter_total if letter_total != 0 else (math.inf if symbol_count > 0 else math.nan) + if (symbol_count >= 5 and symbol_ratio > 0.2 + or top_gap < 0.2 * other_block.avg_font_size() + or top_gap < min(other_block.avg_font_size(), 0.7 * predecessor_gap)): + return None + + centered = other_block.char_stats.secondary_slot == 2 + neg = -0.2 * last_span(last_line_of(other_block)).bbox_height() if caps_heavy else 0 + + # Branch A: tight criteria with neighbor analysis + neighbor_heading_cue = ( + predecessor_gap < math.inf and predecessor_gap > neg + and other_block.avg_font_size() >= page.primary_slot - 0.1 + and predecessor is not None and other_block.avg_font_size() >= predecessor.avg_font_size() - 0.1 + and other_block.avg_font_size() >= page_scan.secondary_slot.secondary_slot.primary_slot - 0.5 + and ((predecessor.is_body_paragraph and other_block.bbox_width() < 0.95 * predecessor.bbox_width() + and x_aligned(other_block, predecessor, max(1, other_block.bbox_width() / 10)) + and predecessor_gap < 6 * other_block.bbox_height()) + or (top_gap < math.inf and above is not None and above.is_body_paragraph + and other_block.bbox_width() < 0.95 * above.bbox_width() + and x_aligned(other_block, above, max(1, other_block.bbox_width() / 10)) + and top_gap < 6 * other_block.bbox_height())) + and (centered or caps_heavy) + and ((other_block.bold_frac() > predecessor.bold_frac() and other_block.bold_frac() > 0.5 + and (not first_span_of(predecessor).primary_slot + or (above is not None and other_block.bold_frac() > above.bold_frac()))) + or caps_heavy) + ) + # Nearby body text with the dominant style changed is a strong heading cue. + difference_style = bool( + predecessor is not None and predecessor.is_body_paragraph + and predecessor.avg_font_size() > page.primary_slot - 0.5 + and predecessor_gap > 0 and predecessor_gap < 3 * other_block.bbox_height() + and dominant_style_of(other_block) != dominant_style_of(predecessor) + ) + style_change_cue = ( + difference_style + and above is not None and above.is_body_paragraph + and top_gap > 0 and top_gap < 3 * other_block.bbox_height() + and centered and dominant_style_of(above) == dominant_style_of(predecessor) if predecessor is not None else False + ) + if neighbor_heading_cue or style_change_cue: + return make_plain_candidate(page_scan, 0, other_block) + + # Top-like context: there is no above block, or the above block is already a + # title/heading marker. + topnum = above is None or above.type == 1 + branch_C1 = ( + topnum and centered and difference_style + and predecessor_gap < other_block.bbox_height() + and predecessor is not None and dominant_style_of(predecessor) == page.auxiliary_slot + ) + branch_C2 = ( + topnum and centered + and predecessor is not None and above_or_overlap is not None + and predecessor is not above_or_overlap + and predecessor.bottom_edge() - above_or_overlap.top_edge() < predecessor.avg_font_size() + and dominant_style_of(above_or_overlap) == page.auxiliary_slot and dominant_style_of(other_block) != page.auxiliary_slot + and (other_block.avg_font_size() >= predecessor.avg_font_size() + 0.5 + or (caps_heavy and not is_upper_dominant(predecessor.char_stats))) + ) + branch_C3 = ( + above is not None + and (above.used_as_heading or above in page_scan.measure_slot) + and (above.avg_font_size() >= other_block.avg_font_size() + 0.5 + or (is_upper_dominant(above.char_stats) and not caps_heavy)) + and centered and difference_style + and predecessor is not None and dominant_style_of(predecessor) == page.auxiliary_slot + ) + if branch_C1 or branch_C2 or branch_C3: + return make_plain_candidate(page_scan, 0, other_block) + + return None + + +def detect_heading_with_body(page_scan: PageScanState, other_block: Block) -> Optional[HeadingCandidate]: + """. Detect heading-with-body 2-line patterns.""" + if other_block.line_count() < 2: + return None + tokens = tokenize_block(other_block) + first_line = other_block.line() + second_line = other_block.primary_slot[1] + split = 0 + letter_count = 0 + font = first_line.primary_slot[0].font_name if first_line.primary_slot else "" + if first_line.bold_frac() > 0 and first_line.bold_frac() < 1: + for entry in enumerate_tokens(tokens): + index = entry["index"] + anchor_token = entry["token"] + if anchor_token.line() is not first_line or not first_anchor_span(anchor_token).primary_slot: + break + if anchor_token.type == 2 and len(anchor_token.str) > 1: + letter_count += 1 + split = index + 1 + elif last_span(last_line_of(other_block)).font_name != font: + other_count = 0 + for candidate_line in other_block: + if candidate_line is not first_line and candidate_line.primary_slot[0].font_name == font: + other_count += 1 + if other_count > other_block.line_count() / 4: + return None + for entry in enumerate_tokens(tokens): + index = entry["index"] + anchor_token = entry["token"] + line = anchor_token.line() + if first_anchor_span(anchor_token).font_name != font or (line is not first_line and line is not second_line): + break + if anchor_token.type == 2 and (len(anchor_token.str) > 1 or anchor_token.primary_slot == 4): + letter_count += 1 + split = index + 1 + if split <= 0 or split >= tokens.length: + return None + # Allow up to two punctuation-like tokens to stay with the prefix when they + # remain on the same line and bracket attachment permits it. + token = tokens.token_at(split - 1) + next_token = tokens.token_at(split) + for _ in range(2): + if token is None or next_token is None: + return None + last_anchor = last_token_anchor(token) + if not (is_word_token(next_token) + and getattr(last_anchor, "line", None) is next_token.line() + and (not token.boundary_slot or next_token.boundary_slot)): + break + split += 1 + token = next_token + next_token = tokens.token_at(split) + if token is None or next_token is None: + return None + if letter_count <= 0: + return None + prefix = tokens.slice(0, split) + + # First-token style check for prefix/body split confidence. + first = prefix.token_at(0) + first_anchor = first_anchor_span(first) if first is not None else None + if first_anchor is not None: + if not first_anchor.primary_slot and not first_anchor.measure_slot and other_block.previous_slot > 0.5: + return None + if (not first_anchor.primary_slot and first_anchor.font_size < other_block.avg_font_size() + 1): + rest = tokens.slice(split) + if rest.length <= 0 or (rest.token_at(0) is not None and rest.token_at(0).primary_slot == 3): + return None + + # Reject prefixes that are only section keywords and contain no extra text. + hn_match = trie_prefix_match(KEYWORDS_SECTION_TRIE, prefix) + if hn_match is not None and len(hn_match) >= letter_count: + return None + + # Reuse numbered-heading detection on the prefix. + heading_kind = detect_numbered_heading(page_scan, other_block, prefix) + if heading_kind is not None and len(heading_kind.numbering) > 1: + return make_heading_candidate(page_scan, heading_kind.type, heading_kind.group_slot, heading_kind.numbering, heading_kind.secondary_slot, heading_kind.primary_slot, True) + if heading_kind is not None and (trie_matches_all(INTRODUCTION_SECTION_TRIE, heading_kind.primary_slot) or (is_uppercase_dominant(heading_kind.primary_slot) and not is_bibliography_entry(other_block))): + return make_heading_candidate(page_scan, heading_kind.type, heading_kind.group_slot, heading_kind.numbering, heading_kind.secondary_slot, heading_kind.primary_slot, True) + + # Reuse the labeled-heading detector on the prefix with body-heading status. + if prefix.length > 3: + heading_signature = detect_labeled_heading(page_scan, other_block, prefix) + if heading_signature is not None: + return make_heading_candidate(page_scan, heading_signature.type, heading_signature.group_slot, heading_signature.numbering, heading_signature.secondary_slot, trim_trailing_punct(heading_signature.primary_slot), True) + + if matches_abstract(prefix): + return make_body_heading_candidate(page_scan, 5, other_block, prefix) + if trie_matches_all(INTRODUCTION_SECTION_TRIE, prefix): + return make_body_heading_candidate(page_scan, 11, other_block, prefix) + + # Final font-size and trailing-token reject gates. + if first_line.avg_font_size() < page_scan.secondary_slot.secondary_slot.primary_slot - 2: + return None + if token is not None and is_word_token(token) and not is_trimmable_token(token): + return None + + # Body paragraphs can still contain an all-caps heading prefix. + if (not is_upper_dominant(other_block.char_stats) and other_block.is_body_paragraph and info_weight(other_block.char_stats) >= 100): + all_caps_vf = CharStats(prefix.to_string()) + if is_upper_dominant(all_caps_vf) and all_caps_vf.primary_slot[2] <= other_block.char_stats.primary_slot[3]: + if heading_kind is not None: + return make_heading_candidate(page_scan, heading_kind.type, heading_kind.group_slot, heading_kind.numbering, heading_kind.secondary_slot, heading_kind.primary_slot, True) + if trie_matches_all(SECTION_KEYWORDS_TRIE, prefix): + return make_body_heading_candidate(page_scan, 6, other_block, prefix) + return make_body_heading_candidate(page_scan, 0, other_block, prefix) + + # Centered two-line heading branch. + above_neighbor = neighbor_above(page_scan.tertiary_slot, other_block) + gap = (above_neighbor.bottom_edge() - first_line.top_edge()) if above_neighbor is not None else math.inf + intersection = first_line.bottom_edge() - second_line.top_edge() + per_char = avg_char_width(first_line) + centered_flag = False + # When there is no above block, the infinite gap is sufficient for this + # branch and later above-block checks must remain guarded. + if first_anchor is not None: + cond_outer = ( + first_anchor.measure_slot + and not first_anchor_span(next_token).measure_slot if next_token is not None else False + ) + # First-line anchor, second-line anchor, gap, neighbor, and punctuation + # checks together identify a centered heading prefix. + if (first_anchor.measure_slot + and next_token is not None and not first_anchor_span(next_token).measure_slot + and (gap > 1.1 * intersection + or (last_token(tokenize_block(above_neighbor)) is not None and is_word_token(last_token(tokenize_block(above_neighbor)))) + or last_line_of(above_neighbor).right_edge() < first_line.right_edge() - 8 * per_char) + and (first_line.right_edge() > second_line.right_edge() - 4 * per_char + or first_line.char_stats.tertiary_slot != 6 + or second_line.char_stats.secondary_slot == 3)): + for prefix_token in prefix: + if prefix_token.primary_slot == 2: + centered_flag = True + break + if prefix_token.type == 2 or prefix_token.boundary_slot: + break + if (centered_flag + and token is not None and is_trimmable_token(token) + and next_token is not None and next_token.primary_slot == 2): + if trie_matches_all(SECTION_KEYWORDS_TRIE, prefix): + return make_body_heading_candidate(page_scan, 6, other_block, prefix) + if letter_count > 1: + return make_body_heading_candidate(page_scan, 0, other_block, prefix) + return None diff --git a/pageindex/flash/heading_detection/text_checks.py b/pageindex/flash/heading_detection/text_checks.py new file mode 100644 index 000000000..b740ab2e4 --- /dev/null +++ b/pageindex/flash/heading_detection/text_checks.py @@ -0,0 +1,214 @@ +"""Block-text predicates: keyword matches, continuation, content, and number parsing.""" + +from __future__ import annotations + +import math +from typing import Any, Optional +from ..labels import is_uppercase_dominant, trie_matches_all, advance_past_line, skip_bracketed_word, token_case_signal, format_caption_label, CaptionEntry, extract_structural_number +from ..model import ( + _UNICODE_WHITESPACE_CLASS, + _strip_diacritics, + _trim_unicode_ws, + style_key, magnitude_ratio, same_x_extent, same_y_extent, y_overlaps, left_aligned, right_aligned, center_aligned, x_aligned, x_centers_close, to_number, + last_span, avg_char_width, raw_text_of_line, heading_score, numbering_text, numbering_value, numbering_kind, Line, last_line_of, first_span_of, is_word_category, block_text, is_punct_category, deaccented_text, letter_count, punct_count, dominant_style_of, + info_weight, dominant_font_size, is_upper_dominant, is_caps_heavy, CharStats, alignment_code, Block, +) +from ..tokens import ( + is_trimmable_token, token_numeric_value, Token, TokenView, wrap_tokens, enumerate_tokens, last_token, trie_prefix_match, strip_trie_match, strip_leading_if_in, COMMA_CHARS, strip_trailing_comma, first_token, trim_trailing_punct, set_case_fold, TrieConfig, build_trie, tokenize_block, + trie_full_match, last_token_anchor, first_anchor_span, is_char_token, is_word_token, +) + +from .keyword_tables import ( + ABSTRACT_KEYWORDS_TRIE, + REFERENCES_TRIE, + _normalize_text_key, + ABSTRACT_KEYWORDS_SET, + REFERENCES_SET, + NUMBERED_PREFIX_RE, + DEAD_DIGIT_RE, + EQUATION_KEYWORDS_TRIE, + ENGLISH_WORD_TO_NUMBER, + ROMAN_NUMERAL_MAP, + FORMULA_CHAR_WEIGHTS, +) + + +def token_text_of_block(block: Block) -> str: + """Tokenize ``block``, join tokens using their stored spacing flags, trim the result, and memoize it on the block.""" + if block.token_text_cache is not None: + return block.token_text_cache + block.token_text_cache = _trim_unicode_ws(tokenize_block(block).to_string()) + + return block.token_text_cache + + +# --------------------------------------------------------------------------- # +# Simple heading and equation predicates. +# --------------------------------------------------------------------------- # + + +def similar_style(block: Block, other_block: Block) -> bool: + """Return whether two blocks have very similar bold ratio and font size.""" + return abs(block.bold_frac() - other_block.bold_frac()) < 0.5 and abs(block.avg_font_size() - other_block.avg_font_size()) < 1 + + +def is_heading_continuation(block: Block, other_block: Block, candidate_number: int) -> bool: + """Return whether ``block`` is the next numbered heading continuation of ``other_block``.""" + if block.type != 0 or block.char_count() >= 500 or not similar_style(other_block, block): + return False + text = token_text_of_block(block) + if block.left_edge() >= other_block.left_edge() and text.startswith("•"): + return True + if is_upper_dominant(other_block.char_stats) and is_upper_dominant(block.char_stats) and not left_aligned(other_block, block, 1) and not right_aligned(other_block, block, 1) and center_aligned(other_block, block, 1): + return False + heading = NUMBERED_PREFIX_RE.match(text) + if heading and len(heading.groups()) >= 1: + matched_number = to_number(heading.group(1)) + return abs(candidate_number - matched_number) == 1 + return False + + +def matches_abstract(tokens: TokenView) -> bool: + """Token sequence matches abstract keywords or their normalized text set.""" + if trie_matches_all(ABSTRACT_KEYWORDS_TRIE, tokens): + return True + if tokens.length > 10: + return False + normalized = "" + for candidate_item in tokens: + if is_word_token(candidate_item): + continue + if candidate_item.type != 2 or len(normalized) + len(candidate_item.str) > 20: + return False + normalized += _normalize_text_key(candidate_item.str.lower()) + return normalized in ABSTRACT_KEYWORDS_SET + + +def matches_references(tokens: TokenView) -> bool: + """Token sequence matches references keywords or their whole-text set.""" + secondary_item = trie_prefix_match(REFERENCES_TRIE, tokens) + if secondary_item is None: + if tokens.length <= 15: + normalized = "" + for candidate_item in tokens: + if is_word_token(candidate_item): + continue + if candidate_item.type != 2 or len(normalized) + len(candidate_item.str) > 20: + return False + normalized += candidate_item.str.lower() + return normalized in REFERENCES_SET + return False + if secondary_item.length == tokens.length: + return True + rest = tokens.slice(secondary_item.length) + if rest.length == 1: + first = rest.token_at(0) + if first is not None and is_word_token(first): + return True + return trie_matches_all(REFERENCES_TRIE, rest) + + +def vertically_close(block: Optional[Block], other_block: Block) -> bool: + """a is vertically very close to b.""" + if block is None: + return False + candidate_item = block.bottom_edge() - other_block.top_edge() if block.top_edge() > other_block.top_edge() else other_block.bottom_edge() - block.top_edge() + return candidate_item < 2 * other_block.avg_font_size() or (x_aligned(block, other_block, 1) and candidate_item < 5 * other_block.avg_font_size()) + + +def is_equation_adjacent_line(line: Optional[Line], block: Block) -> bool: + """Return whether a line is adjacent to an equation block: it overlaps and follows the block, matches the equation-separator pattern, or consists entirely of equation-keyword tokens after trimming wrapper punctuation.""" + from ..labels import extract_structural_number + if line is None or line.line_count() != 1: + return False + if line.left_edge() < block.right_edge() or not y_overlaps(block, line): + return False + if DEAD_DIGIT_RE.match(block_text(line)): + return True # Equation separator match is enough to accept. + tokens = tokenize_block(line) + # Drop single non-digit chars at both edges when token-count is >= 3. + if (tokens.length >= 3 + and (first := first_token(tokens)) is not None and len(first.str) <= 1 + and first.type != 1 + and (last := last_token(tokens)) is not None and len(last.str) <= 1 + and last.type != 1): + tokens = tokens.slice(1, tokens.length - 1) + tokens = strip_trie_match(tokens, EQUATION_KEYWORDS_TRIE) + yi_match = extract_structural_number(tokens) + return yi_match is not None and yi_match.length == tokens.length + + +def has_substantive_content(block: Block, other_block: Optional[Block], candidate_block: Optional[Block]) -> bool: + """heuristic "this block has substantive content?" score >= 5.""" + entry_item = 0 + for token in tokenize_block(block): + anchor = first_anchor_span(token) + line = token.line() + size = line.previous_slot + flag = anchor.top_edge() < line.bottom_edge() + 0.8 * size or anchor.bottom_edge() > line.top_edge() - 0.8 * size + if token.type == 1: + entry_item += 2 if flag else 1 + continue + weight = FORMULA_CHAR_WEIGHTS.get(token.str) + if weight is not None: + entry_item += (3 if flag else 1) * weight + continue + if token.type == 6: + entry_item += (3 if flag else 1) * 5 + continue + if len(token.str) <= 3 and token.primary_slot != 4: + if flag: + entry_item += 5 if is_word_token(token) else 1 + continue + if flag: + continue + len_value = (2 if anchor.primary_slot else 1) * len(token.str) + if token.primary_slot == 4: + entry_item -= 2 * len_value + elif token.primary_slot == 2: + entry_item -= len_value + elif token.primary_slot == 3: + entry_item -= 0.5 * len_value + if entry_item < 0: + return False + if entry_item >= 5: + return True + return is_equation_adjacent_line(other_block, block) or is_equation_adjacent_line(candidate_block, block) + + +def is_cover_page(doc, page) -> bool: + """Return whether ``page`` behaves like a cover page: it is title-marked, appears early, and has light content or no body text.""" + return ( + page.auxiliary_slot + and page.page_index < max(2, len(doc.primary_slot) / 2) + and ( + page.primary_slot.secondary_slot < clamp(0.5 * doc.secondary_slot.secondary_slot, 200, 1000) + or not page.state_slot + ) + ) + + +def clamp(value: float, lower_bound: float, upper_bound: float) -> float: + """``max(lo, min(hi, v))``. NaN propagates.""" + measure_item = upper_bound if upper_bound < value else value + return lower_bound if lower_bound > measure_item else measure_item + + +def token_to_number(tok: Optional[Token]) -> Optional[int | float]: + """extract numeric value from a token (digit, Roman, or English).""" + if tok is None: + return None + if tok.type == 1: + token = token_numeric_value(tok) + if not math.isnan(token) and token > 0: + return int(token) if token.is_integer() else token + return None + return ROMAN_NUMERAL_MAP.get(tok.str) or ENGLISH_WORD_TO_NUMBER.get(tok.str.lower()) + + +def letter_to_ordinal(tok_str: str) -> Optional[int]: + """'a'/'A' -> 1, 'b' -> 2, ..., 'h' -> 8. None otherwise.""" + if len(tok_str) != 1: + return None + value = ord(tok_str[0].lower()) - ord("a") + 1 + return value if 1 <= value <= 8 else None diff --git a/pageindex/flash/labels/__init__.py b/pageindex/flash/labels/__init__.py new file mode 100644 index 000000000..f2690826b --- /dev/null +++ b/pageindex/flash/labels/__init__.py @@ -0,0 +1,48 @@ +"""Keyword-labeled section and caption-region detection. This module finds blocks that look like figure/table/chart labels or named +sections, then extends each label forward or backward to claim the associated +body blocks. The resulting regions are used by classification and outline +assembly to avoid treating captions or labeled content as ordinary headings. +""" + +import regex as regex_module # Unicode \p{...} property classes. +from typing import Optional + +from ..classification import FIGURE_KEYWORDS_TRIE, TABLE_KEYWORDS_TRIE, CHART_KEYWORDS_TRIE +from ..model import ( + Rect, rect_union, extend_top_to, extend_bottom_to, EMPTY_RECT, Bounded, + _trim_unicode_ws, + center_aligned, last_span, heading_score, reading_order_key, numbering_text, Line, last_line_of, first_span_of, dominant_style_of, info_weight, Block, +) +from ..stats import column_index_of +from ..tokens import Token, TokenView, wrap_tokens, enumerate_tokens, last_token, trie_prefix_match, strip_leading_if_in, first_token, set_case_fold, TrieConfig, build_trie, tokenize_block, BuiltTrie, is_word_token + +from .caption_text import ( + PERIOD_CHARS, + STRUCTURAL_NUMBER_RE, + is_number_separator, + extract_structural_number, + format_caption_label, + REFERENCE_PHRASE_TRIE, + is_uppercase_dominant, + trie_matches_all, + advance_past_line, + skip_bracketed_word, + token_case_signal, + caption_outranks, +) +from .caption_regions import ( + CaptionedRegion, + dedupe_caption_entries, + extend_caption_region, + build_caption_regions, + CaptionEntry, + CaptionContext, + iter_page_blocks, + detect_captions, +) + +__all__ = [ + "PERIOD_CHARS", "STRUCTURAL_NUMBER_RE", "is_number_separator", "extract_structural_number", "format_caption_label", "REFERENCE_PHRASE_TRIE", + "is_uppercase_dominant", "trie_matches_all", "advance_past_line", "skip_bracketed_word", "token_case_signal", "caption_outranks", + "CaptionEntry", "CaptionedRegion", "CaptionContext", "iter_page_blocks", "detect_captions", "dedupe_caption_entries", "extend_caption_region", "build_caption_regions", +] diff --git a/pageindex/flash/labels/caption_regions.py b/pageindex/flash/labels/caption_regions.py new file mode 100644 index 000000000..748b61612 --- /dev/null +++ b/pageindex/flash/labels/caption_regions.py @@ -0,0 +1,366 @@ +"""Caption region growth, deduplication, and detection.""" + +from __future__ import annotations + +from typing import Optional + +from ..classification import FIGURE_KEYWORDS_TRIE, TABLE_KEYWORDS_TRIE, CHART_KEYWORDS_TRIE +from ..model import ( + Rect, rect_union, extend_top_to, extend_bottom_to, EMPTY_RECT, Bounded, + _trim_unicode_ws, + center_aligned, last_span, heading_score, reading_order_key, numbering_text, Line, last_line_of, first_span_of, dominant_style_of, info_weight, Block, +) +from ..stats import column_index_of +from ..tokens import Token, TokenView, wrap_tokens, enumerate_tokens, last_token, trie_prefix_match, strip_leading_if_in, first_token, set_case_fold, TrieConfig, build_trie, tokenize_block, BuiltTrie, is_word_token + +from .caption_text import ( + PERIOD_CHARS, + extract_structural_number, + format_caption_label, + REFERENCE_PHRASE_TRIE, + caption_outranks, +) + + +# --------------------------------------------------------------------------- # +# Captioned/labeled region wrapper # +# --------------------------------------------------------------------------- # + + +class CaptionedRegion(Bounded): + """Captioned or labeled region plus its body blocks. The region stores the document context, page, heading block, body blocks, neighboring block reference, label flag, label type, and an area-weighted score used to choose forward vs backward extension.""" + + __slots__ = ("weighted_ratio_primary", "page", "primary_slot", "output_slot", "state_slot", "alignment_slot", "type", "score") + + def __init__(self, primary_item, secondary_item, candidate_item, bbox: Rect, blocks, next_item, flag): + super().__init__(bbox) + self.weighted_ratio_primary = primary_item + self.page = secondary_item + self.primary_slot = candidate_item # the original heading block + self.output_slot = blocks # list of body blocks + self.state_slot = next_item + self.alignment_slot = flag + # Caption label type is carried by the heading block marker. + # ``Block.type`` is a later classification label and is still zero here. + self.type = candidate_item.marker_slot + # Region score formula. + area_pct = 100.0 * self.area() / self.page.bounds.area() if self.page.bounds.area() > 0 else 0.0 + if area_pct <= 0: + score = 0.0 + else: + if (self.state_slot is not None + and self.state_slot.top_edge() < self.top_edge() + and self.state_slot.right_edge() > self.left_edge() + and self.alignment_slot): + area_pct /= 5.0 + if self.type == 4: + inner = 0.0 + for block in self.output_slot: + if block.skew_frac() > 1: + continue + inner += block.area() + score = area_pct * max(0.1, 1 - inner / self.area()) if self.area() > 0 else 0.0 + else: + # Span text is a string, so every span contributes its character + # count to the caption-region score. + count = 1.0 + for block in self.output_slot: + for line in block: + for span in line: + count += span.char_count() + score = count * area_pct + self.score = score + + +# --------------------------------------------------------------------------- # +# Deduplicate caption entries and keep the best entry for each label. +# --------------------------------------------------------------------------- # + + +def dedupe_caption_entries(caption_context: "CaptionContext") -> list["CaptionEntry"]: + """Deduplicate structural-number entries by label while preserving page order.""" + if not caption_context.state_slot: + return caption_context.auxiliary_slot + captions_by_label: dict[str, CaptionEntry] = {} + for caption in caption_context.auxiliary_slot: + if len(caption.primary_slot) <= 1: + continue + existing = captions_by_label.get(caption.primary_slot) + if existing is None or caption_outranks(caption, existing): + captions_by_label[caption.primary_slot] = caption + out = list(captions_by_label.values()) + out.sort(key=lambda caption_sort_key: (caption_sort_key.page_index, caption_sort_key.group_slot.reading_order_index)) + return out + + +# --------------------------------------------------------------------------- # +# Extend a labeled section forward or backward. +# --------------------------------------------------------------------------- # + + +def extend_caption_region( + caption_context: "CaptionContext", + entry: "CaptionEntry", + prior_regions: list, + page_set: Optional[set], + direction: int, +) -> Optional[CaptionedRegion]: + """Walk page blocks forward or backward from a labeled entry, accumulating a region until an already-classified block, claimed block, deep body block, fresh top-level heading, or size/gap boundary is reached.""" + page = caption_context.primary_slot.primary_slot[entry.page_index - 1] + origin = entry.group_slot + anchor = origin.bottom_edge() if direction > 0 else origin.top_edge() + bbox = Rect(origin.left_edge(), origin.right_edge(), anchor, anchor) + blocks: list[Block] = [] + sorted_value = page.secondary_slot + index = entry.group_slot.reading_order_index + direction + previous: Block = origin + + while 0 <= index < len(sorted_value): + caption = sorted_value[index] + caption_column = column_index_of(caption) + if caption_column < 0: + break + # layout branch: when crossing the column band, walk page.j (column + # rects) to the nearest column that horizontally overlaps the + # bbox and extend the bbox vertically to that column's edge. + if direction < 0 and caption_column < column_index_of(entry.group_slot) and caption.bottom_edge() < anchor: + col_idx = caption_column - 1 + column_rect = page.tertiary_slot[col_idx] if 0 <= col_idx < len(page.tertiary_slot) else None + while column_rect is not None and ( + column_rect.bottom_edge() < bbox.top_edge() + or column_rect.right_edge() < bbox.left_edge() + or column_rect.left_edge() > bbox.right_edge() + ): + col_idx -= 1 + column_rect = page.tertiary_slot[col_idx] if 0 <= col_idx < len(page.tertiary_slot) else None + if column_rect is not None: + bbox = extend_top_to(bbox, column_rect.bottom_edge()) + else: + bbox = extend_top_to(bbox, page.bounds.top_edge()) + break + if direction > 0 and caption_column > column_index_of(entry.group_slot) and caption.top_edge() > anchor: + col_idx = caption_column + 1 + column_rect = page.tertiary_slot[col_idx] if 0 <= col_idx < len(page.tertiary_slot) else None + while column_rect is not None and ( + column_rect.top_edge() > bbox.bottom_edge() + or column_rect.right_edge() < bbox.left_edge() + or column_rect.left_edge() > bbox.right_edge() + ): + col_idx += 1 + column_rect = page.tertiary_slot[col_idx] if 0 <= col_idx < len(page.tertiary_slot) else None + if column_rect is not None: + bbox = extend_bottom_to(bbox, column_rect.top_edge()) + else: + bbox = extend_bottom_to(bbox, page.bounds.bottom_edge()) + break + # Grow the bbox to include n + if direction < 0: + bbox = extend_top_to(bbox, caption.bottom_edge()) + else: + bbox = extend_bottom_to(bbox, caption.top_edge()) + # Stop conditions + if caption.type != 0 or caption.reading_order_index in caption_context.secondary_slot: + break + if page_set is not None and index in page_set: + break + size = min(caption_context.primary_slot.secondary_slot.primary_slot, entry.group_slot.avg_font_size()) + if caption.is_body_paragraph and caption.avg_font_size() > min(0.9 * size, size - 1.5): + break + next_block = sorted_value[index + 1] if index + 1 < len(sorted_value) else None + gap = previous.bottom_edge() - caption.top_edge() if direction > 0 else 0 + line_gap = page.primary_slot.tertiary_slot - page.primary_slot.primary_slot + if ( + direction > 0 and next_block is not None and caption.line_count() <= 4 and caption.char_stats.secondary_slot != 3 + and gap > line_gap + and (previous is entry.group_slot or gap > min(3 * line_gap, caption.bottom_edge() - next_block.top_edge())) + ): + next_item = sorted_value[index + 2] if index + 2 < len(sorted_value) else None + if heading_score(caption) >= heading_score(previous) + 0.5 and (next_block.is_body_paragraph or (next_item is not None and next_item.is_body_paragraph)): + break + # A numbering-like line with enough trailing text can stop this + # backward body-paragraph scan. + line_text = numbering_text(caption.line()) + if (line_text + and caption.char_stats.secondary_slot == 2 + and heading_score(caption) >= size + and gap > 2 * caption.avg_font_size() + and caption.char_count() - len(line_text) > 2): + break + blocks.append(caption) + bbox = rect_union(bbox, caption.secondary_slot) + index += direction + previous = caption + + if direction < 0 and index < 0: + bbox = extend_top_to(bbox, page.bounds.top_edge()) + elif direction > 0 and index >= len(sorted_value): + bbox = extend_bottom_to(bbox, page.bounds.bottom_edge()) + + # When a backward extension expands the region, also consume forward + # neighbours whose geometric center sits inside the grown bbox. + if direction < 0: + fwd_idx = entry.group_slot.reading_order_index + 1 + while fwd_idx < len(sorted_value): + block = sorted_value[fwd_idx] + center_x = block.center_x() + center_y = block.center_y() + if (center_x < bbox.left_edge() or center_x > bbox.right_edge() + or center_y < bbox.bottom_edge() or center_y > bbox.top_edge()): + break + blocks.append(block) + bbox = rect_union(bbox, block.secondary_slot) + fwd_idx += 1 + + area = bbox.area() + if area <= 0: + return None + + # Check overlap with prior regions; if heavy overlap, reject. + for prior in prior_regions: + overlap_area = max( + 0.0, + min(bbox.right, prior.secondary_slot.right) - max(bbox.left, prior.secondary_slot.left), + ) * max( + 0.0, + min(bbox.top, prior.secondary_slot.top) - max(bbox.primary_slot, prior.secondary_slot.primary_slot), + ) + if overlap_area >= 0.25 * min(area, prior.area()): + return None + + next_block = sorted_value[index] if 0 <= index < len(sorted_value) else None + on_page_set = page_set is not None and index in page_set + return CaptionedRegion( + primary_item=caption_context.primary_slot, secondary_item=page, candidate_item=entry.group_slot, + bbox=bbox, blocks=blocks, next_item=next_block, flag=on_page_set, + ) + + +# --------------------------------------------------------------------------- # +# Extend all deduplicated labeled-section entries. +# --------------------------------------------------------------------------- # + + +def build_caption_regions(caption_context: "CaptionContext") -> list[CaptionedRegion]: + """Build caption regions by extending each labeled entry in both directions.""" + caption_context.tertiary_slot.clear() + caption_context.secondary_slot.clear() + entries = dedupe_caption_entries(caption_context) + for caption in entries: + set_value = caption_context.tertiary_slot.get(caption.page_index) + if set_value is None: + set_value = set() + caption_context.tertiary_slot[caption.page_index] = set_value + set_value.add(caption.group_slot.reading_order_index) + out: list[CaptionedRegion] = [] + page = 0 + prior_regions: list[CaptionedRegion] = [] + for entry in entries: + if entry.page_index != page: + prior_regions = [] + caption_context.secondary_slot.clear() + page = entry.page_index + if len(prior_regions) >= 8: + continue + page_set = caption_context.tertiary_slot.get(entry.page_index) + back = extend_caption_region(caption_context, entry, prior_regions, page_set, -1) + forward = extend_caption_region(caption_context, entry, prior_regions, page_set, 1) + winner = ( + back if (back is not None and (forward is None or back.score > forward.score)) + else forward + ) + if winner is not None: + for body_block in winner.output_slot: + caption_context.secondary_slot.add(body_block.reading_order_index) + prior_regions.append(winner) + out.append(winner) + return out + + +# --------------------------------------------------------------------------- # +# Labeled-section entry. +# --------------------------------------------------------------------------- # + + +class CaptionEntry: + """One labeled-section entry with label, type, page, block, and remainder tokens.""" + + __slots__ = ("primary_slot", "type", "page_index", "group_slot", "secondary_slot") + + def __init__(self, label: str, type_: int, page: int, block: Block, remainder: TokenView): + self.primary_slot = label + self.type = type_ + self.page_index = page + self.group_slot = block + self.secondary_slot = remainder + + +# --------------------------------------------------------------------------- # +# Labeled-section context. +# --------------------------------------------------------------------------- # + + +class CaptionContext: + """Document-level state for labeled-section detection.""" + + __slots__ = ("primary_slot", "auxiliary_slot", "state_slot", "tertiary_slot", "secondary_slot") + + def __init__(self, doc): + self.primary_slot = doc + self.auxiliary_slot: list[CaptionEntry] = [] + self.state_slot: bool = False + self.tertiary_slot: dict = {} # page -> set of heading-block ga + self.secondary_slot: set = set() # set of heading-block ga across doc + + +# --------------------------------------------------------------------------- # +# Document-wide (page, block) iterator. +# --------------------------------------------------------------------------- # + + +def iter_page_blocks(doc): + """Yield ``{'page': page, 'G': block}`` records in reading order.""" + for page in doc.primary_slot: + for block in (page.secondary_slot or []): + yield {"page": page, "block": block} + + +# --------------------------------------------------------------------------- # +# Labeled-section detection driver. +# --------------------------------------------------------------------------- # + + +def detect_captions(caption_context: CaptionContext) -> None: + """Find figure, table, and chart labels and record their structural prefixes.""" + for entry in iter_page_blocks(caption_context.primary_slot): + page = entry["page"] + block = entry["block"] + if block.type != 0: + continue + tokens = tokenize_block(block) + type_value: Optional[int] = None + prefix = trie_prefix_match(FIGURE_KEYWORDS_TRIE, tokens) + if prefix is not None: + type_value = 4 + else: + prefix = trie_prefix_match(TABLE_KEYWORDS_TRIE, tokens) + if prefix is not None: + type_value = 5 + else: + prefix = trie_prefix_match(CHART_KEYWORDS_TRIE, tokens) + if prefix is not None: + type_value = 11 + if type_value is None: + continue + + remainder = strip_leading_if_in(tokens.slice(prefix.length), PERIOD_CHARS) + number = extract_structural_number(remainder) + label = format_caption_label(type_value, number) + if number is not None: + caption_context.state_slot = True + remainder = remainder.slice(number.length) + if trie_prefix_match(REFERENCE_PHRASE_TRIE, remainder) is not None: + continue + page.measure_slot = True + caption_context.auxiliary_slot.append(CaptionEntry(label, type_value, page.page_index, block, remainder)) + # Mark the block's Y category (used by outline.py heading filter) + block.marker_slot = type_value diff --git a/pageindex/flash/labels/caption_text.py b/pageindex/flash/labels/caption_text.py new file mode 100644 index 000000000..258acbce5 --- /dev/null +++ b/pageindex/flash/labels/caption_text.py @@ -0,0 +1,171 @@ +"""Caption label text helpers and structural-number parsing.""" + +from __future__ import annotations + +import regex as regex_module # Unicode \p{...} property classes. +from typing import Optional +from ..model import ( + Rect, rect_union, extend_top_to, extend_bottom_to, EMPTY_RECT, Bounded, + _trim_unicode_ws, + center_aligned, last_span, heading_score, reading_order_key, numbering_text, Line, last_line_of, first_span_of, dominant_style_of, info_weight, Block, +) +from ..tokens import Token, TokenView, wrap_tokens, enumerate_tokens, last_token, trie_prefix_match, strip_leading_if_in, first_token, set_case_fold, TrieConfig, build_trie, tokenize_block, BuiltTrie, is_word_token + + +# --------------------------------------------------------------------------- # +# Helpers # +# --------------------------------------------------------------------------- # + + +PERIOD_CHARS = {".", ".", "。", "。"} # period-character set + + +# Structural-number pattern: Unicode numeric code points, optional letter +# affixes, or Roman numerals. ``\Z`` anchors at the absolute end of string, not +# before a trailing newline. +STRUCTURAL_NUMBER_RE = regex_module.compile( + r"^(?:[A-M]*\p{Number}+[A-Ma-m]?|[A-Ma-m]\p{Number}*|[IVX]+)\Z" +) + + +def is_number_separator(token: Optional[Token], other_flag: bool = True) -> bool: + """Return whether the token is a structural-number separator candidate.""" + if token is None: + return False + if token.boundary_slot: + return False + if token.type == 3: + return True + if other_flag and token.type == 4: + return True + return False + + +def extract_structural_number(tokens: TokenView, other_flag: bool = True) -> Optional[TokenView]: + """extract a leading structural-number prefix from tokens. Returns the matched prefix as a token-view slice, or None. """ + if tokens.length < 1: + return None + candidate_item = tokens + first = tokens.token_at(0) + if first is None: + return None + reference_item = first.str + if len(reference_item) == 1 and "A" <= reference_item[0] <= "H": + if not is_number_separator(tokens.token_at(1), other_flag): + return None + candidate_item = tokens.slice(2) + if candidate_item.length < 1: + return None + head = first_token(candidate_item) + if head is None or not STRUCTURAL_NUMBER_RE.match(head.str): + return None + candidate_item = candidate_item.slice(1) + while candidate_item.length >= 2 and is_number_separator(candidate_item.token_at(0), other_flag) and STRUCTURAL_NUMBER_RE.match(candidate_item.token_at(1).str): # type: ignore[union-attr] + candidate_item = candidate_item.slice(2) + return tokens.slice(0, tokens.length - candidate_item.length) + + +# - format code label +def format_caption_label(type_: int, num: Optional[TokenView]) -> str: + """format the section-type letter prefix + number. type_ 4 -> "F", 5 -> "T", 11 -> "Q". Append the number string if any. """ + if type_ == 4: + letter = "F" + elif type_ == 5: + letter = "T" + elif type_ == 11: + letter = "Q" + else: + return "" + if num is not None: + letter += _trim_unicode_ws(str(num)) + return letter + + +# - case-sensitive trie of phrases that indicate "this is a +# reference TO a figure/table, not a label OF one". +REFERENCE_PHRASE_TRIE = build_trie(["lists the", "presents", "show the", "showed the", "shows"], set_case_fold(TrieConfig(), False)) + + +# --------------------------------------------------------------------------- # +# Token helpers for caption-entry ranking. +# --------------------------------------------------------------------------- # + + +def is_uppercase_dominant(tokens: TokenView) -> bool: + """Return True when the token sequence is dominated by uppercase words. Multi-character lowercase-start words whose second character is not uppercase reject the sequence as body-like text.""" + from ..tokens import char_category + secondary_item = candidate_item = 0 + for reference_item in tokens: + if reference_item.type != 2: + continue + if reference_item.primary_slot == 2: + secondary_item += 1 + elif reference_item.primary_slot == 3: + if len(reference_item.str) > 4 and len(reference_item.str) >= 2 and char_category(reference_item.str[1]) != 2: + return False + candidate_item += 1 + return secondary_item > max(2, candidate_item) + + +def trie_matches_all(trie: BuiltTrie, tokens: TokenView) -> bool: + """tokens fully match ``trie`` (or all but a final word-y token).""" + match = trie_prefix_match(trie, tokens) + if match is None: + return False + if match.length == tokens.length: + return True + if match.length == tokens.length - 1: + last = last_token(tokens) + return last is not None and is_word_token(last) + return False + + +def advance_past_line(tokens: TokenView, line: Line, index: int) -> int: + """Advance while the token at the current index belongs to ``line``.""" + while index < tokens.length: + tok = tokens.token_at(index) + if tok is None: + break + if tok.line() is not line: + break + index += 1 + return index + + +def skip_bracketed_word(tokens: TokenView, index: int) -> int: + """advance over bracket-attached word token.""" + tok = tokens.token_at(index) + if tok is not None and tok.boundary_slot and is_word_token(tok): + return index + 1 + return index + + +def token_case_signal(token: Optional[Token]) -> int: + """per-token "direction signal". Returns 2 if g==7/6 (sentence end), 1 if g==2 (uppercase), -1 if g==3 (lowercase), 0 otherwise. """ + if token is None: + return 0 + token_kind = token.primary_slot + if token_kind == 7 or token_kind == 6: + return 2 + if token_kind == 2: + return 1 + if token_kind == 3: + return -1 + return 0 + + +def caption_outranks(caption_entry: "CaptionEntry", other_caption_entry: "CaptionEntry") -> bool: + """Return True when the first caption entry ranks better than the second.""" + caption = is_uppercase_dominant(tokenize_block(caption_entry.group_slot)) + other_is_uppercase = is_uppercase_dominant(tokenize_block(other_caption_entry.group_slot)) + if caption != other_is_uppercase: + return caption + caption_first_token = first_token(caption_entry.secondary_slot) if caption_entry.secondary_slot.length > 0 else None + other_first_token = first_token(other_caption_entry.secondary_slot) if other_caption_entry.secondary_slot.length > 0 else None + group = token_case_signal(caption_first_token) + other_case_signal = token_case_signal(other_first_token) + if group != other_case_signal: + return group > other_case_signal + if caption_entry.page_index != other_caption_entry.page_index: + return caption_entry.page_index < other_caption_entry.page_index + return caption_entry.group_slot.reading_order_index < other_caption_entry.group_slot.reading_order_index diff --git a/pageindex/flash/main.py b/pageindex/flash/main.py new file mode 100644 index 000000000..4fd28f55a --- /dev/null +++ b/pageindex/flash/main.py @@ -0,0 +1,294 @@ +""" +End-to-end orchestrator for the TOC extraction pipeline. Pipeline order: 1. parse character-level spans and page viewport metadata 2. cluster spans into lines 3. compute page statistics 4. detect columns and recluster lines with column awareness 5. remove line-number artifacts and recompute statistics 6. compute document-level statistics 7. cluster lines into blocks and assign reading order 8. classify headers, footers, watermarks, TOC-like pages, captions, references, and body paragraphs 9. detect the document title 10. collect heading candidates and assemble the final outline The ordering is load-bearing: title selection, labeled-section detection, +heading candidate collection, and outline assembly each consume annotations +from the previous stages. ``to_pageindex_tree`` serializes the final outline +into the JSON shape that ``run_pageindex.py`` writes. +""" + +from __future__ import annotations + +import json +import re +import unicodedata +from io import BytesIO +from pathlib import Path +from typing import Optional, Union + +# (re is used by the title-reject regex below) + +from .blocks import cluster_lines_into_blocks, BlockClusterContext +from .classification import is_body_paragraph, detect_header_footer, HeaderFooterContext, mark_watermarks, mark_toc_and_boilerplate +from .labels import detect_captions, build_caption_regions, CaptionContext +from .model import Rect, numbering_kind, block_text, deaccented_text, Block +from .outline_assembly import ( + build_heading_from_block, is_landscape_or_empty, is_outline_valid, is_chapter_outline_valid, mark_outline_block_types, assemble_outline, compute_max_heading_gap, OutlineNode, outline_to_dict_tree, +) +from .parser_pdfium_charlevel import parse_charlevel_meta +from .phases import assign_reading_order, PageView, process_page +PageView = PageView # re-export for type hints +from .stats import compute_doc_stats +from .title import detect_title + + +# --------------------------------------------------------------------------- # +# References-section dictionary (load once) # +# --------------------------------------------------------------------------- # + + +_DICT_PATH = Path(__file__).parent / "data" / "dictionaries.json" + + +def _normalize_text_key(text: str) -> str: + return " ".join(unicodedata.normalize("NFKC", text).strip().split()).lower() + + +_REFS_DICT_RAW = json.loads(_DICT_PATH.read_text(encoding="utf-8")) +REFERENCES_KEYWORDS = frozenset(_normalize_text_key(text_value) for text_value in _REFS_DICT_RAW.get("references", []) if text_value) + + +# --------------------------------------------------------------------------- # +# Document container # +# --------------------------------------------------------------------------- # + + +class DocumentState: + """Document-level extraction state: pages, document statistics, and recurring-text frequency map. """ + + __slots__ = ("primary_slot", "secondary_slot", "tertiary_slot") + + def __init__(self, pages: list[PageView]): + self.primary_slot = pages + self.secondary_slot = None # set after document statistics are computed + self.tertiary_slot: dict = {} + + +# --------------------------------------------------------------------------- # +# References-section detection # +# --------------------------------------------------------------------------- # + + +def find_references(doc: DocumentState) -> Optional[tuple[int, Block]]: + """Return ``(page_num, block)`` for the first references heading in reading order.""" + for page in doc.primary_slot: + for block in (page.secondary_slot or []): + if block.type != 0: + continue + normalized = deaccented_text(block) + if not normalized or len(normalized) > 80: + continue + if normalized in REFERENCES_KEYWORDS: + return page.page_index, block + # Allow short numbered prefix: "12. References" + parts = normalized.split() + if 1 <= len(parts) <= 4 and parts[-1] in REFERENCES_KEYWORDS: + return page.page_index, block + return None + + +def mark_references(doc: DocumentState, ref: Optional[tuple[int, Block]]) -> None: + """Tag the references heading itself + everything after as type=3.""" + if ref is None: + return + ref_page, ref_block = ref + seen = False + for page in doc.primary_slot: + if page.page_index < ref_page: + continue + for block in (page.secondary_slot or []): + if not seen and block is ref_block: + seen = True + block.type = 3 + continue + if seen: + block.type = 3 + + +# --------------------------------------------------------------------------- # +# Repeated-text accumulator # +# --------------------------------------------------------------------------- # + + +def page_by_block_lookup(pages, block) -> Optional[PageView]: + """Find which page owns ``block``. Used for wrapping labeled blocks.""" + for page in pages: + if block in (page.secondary_slot or []): + return page + return None + + +# --------------------------------------------------------------------------- # +# End-to-end entry point # +# --------------------------------------------------------------------------- # + + +def extract_toc( + doc_handle: Union[str, Path, BytesIO], +) -> dict: + """Run the full pipeline. Returns a dict shaped like:: { "doc_name": "...", "doc_title": "...", "structure": [ {"title": "...", "start_index": 1, "end_index": 3, "nodes": [...]}, ... ] } """ + # ----- 1) Parse PDF -> flat spans per page -------------------------- + # per-page (view box, /Rotate) comes from the same engine (PDFium) that + # produced the block coordinates, so the geometry frame is consistent. + parsed, page_meta = parse_charlevel_meta(doc_handle) + + # ----- 2) Per-page layout classification ---------------------------------- + # Heading coordinate projection uses the page viewport. + pages: list[PageView] = [] + for index_value, spans in enumerate(parsed): + viewport_box_value, rot = page_meta[index_value] + viewport_x0, viewport_y0, viewport_x1, viewport_y1 = viewport_box_value + # page bbox uses DISPLAYED (post-/Rotate) dims. + viewport_width, viewport_height = abs(viewport_x1 - viewport_x0), abs(viewport_y1 - viewport_y0) + page_width, page_height = (viewport_height, viewport_width) if rot % 180 == 90 else (viewport_width, viewport_height) + page_bbox = Rect(0, page_width, page_height, 0) + page = process_page(spans, page_num=index_value + 1, page_bbox=page_bbox) + if viewport_box_value is not None: + page.viewport_box, page.rot = viewport_box_value, rot + pages.append(page) + + # ----- 3) Document-level stats -------------------------------------- + doc = DocumentState(pages) + doc.secondary_slot = compute_doc_stats(pages) + + # ----- 4) Block clustering per page, then reading order ------------- + for page in pages: + ctx = BlockClusterContext(doc.secondary_slot, page.bounds, page.primary_slot, page.lines, page.tertiary_slot) + page.blocks = cluster_lines_into_blocks(ctx) + assign_reading_order(page, page.blocks) + + # ----- Early empty-outline gate ------------------------------------ + # Short, near-empty, unsupported-script, or mostly-landscape documents + # emit an empty outline rather than a fabricated structure. + if (doc.secondary_slot.state_slot <= 300 or doc.secondary_slot.previous_slot <= 200 + or doc.secondary_slot.tertiary_slot in (0, 2, 10) or is_landscape_or_empty(doc)): + if isinstance(doc_handle, (str, Path)): + doc_name = Path(str(doc_handle)).name + else: + doc_name = "document.pdf" + return {"doc_name": doc_name, "doc_title": None, "structure": []} + + # ----- 5) Classification: header / footer / watermark / TOC pages --- + detect_header_footer(HeaderFooterContext(doc, 1)) # HEADER + detect_header_footer(HeaderFooterContext(doc, 2)) # FOOTER + mark_watermarks(doc) + mark_toc_and_boilerplate(doc) + + # ----- 6) Body-paragraph flagging (post-classification) ------------- + # Populates body-paragraph flags, page substantive-body flags, + # and per-page body-style hashes. + from .model import dominant_style_of as span_style_hash + for page in pages: + for block in (page.output_slot or []): + if block.type == 0: + block.is_body_paragraph = is_body_paragraph(doc.secondary_slot, page, block) + if block.is_body_paragraph: + page.state_slot = True + # The empty style hash is significant for later page-level + # membership checks, so it must be retained. + page.style_slot.add(span_style_hash(block)) + + # ----- 7) Title selection ------------------------------------------ + # Title selection and title-echo marking must run before labeled-section + # detection and heading collection so title blocks are excluded from both. + from .classification import bounded_edit_distance, _normalize_text_key + doc_title: Optional[str] = None + title_winner = detect_title(doc) + if title_winner is not None: + # Emit the full joined title string, preserving inter-block spaces. + doc_title = title_winner.to_string() + title_winner.page.auxiliary_slot = True + for block in title_winner.output_slot: + block.type = 3 + title_norm = _normalize_text_key(title_winner.to_string()).lower() + # The body-paragraph break exits only the inner block loop; later + # pages are still scanned for title-echo headers. + for candidate_page in doc.primary_slot: + for candidate_block in (candidate_page.output_slot or []): + if candidate_block.type != 0: + continue + normalized = deaccented_text(candidate_block).lower() + if (len(normalized) > 20 and len(title_norm) > 20 and ( + normalized.startswith(title_norm) + or title_norm.startswith(normalized) + or title_norm.endswith(normalized))): + candidate_page.auxiliary_slot = True + candidate_block.type = 3 + continue + threshold = 0.2 * min(len(normalized), len(title_norm)) + if bounded_edit_distance(normalized, title_norm, threshold) < threshold: + candidate_page.auxiliary_slot = True + candidate_block.type = 3 + elif candidate_block.is_body_paragraph: + break + + # ----- 8) Keyword-labeled section detection ------------------------- + # Labeled section regions are built here but extended after heading collection. + caption_context = CaptionContext(doc) + detect_captions(caption_context) + + # ----- 9) General heading collection -------------------------------- + # Heading collection runs before labeled regions claim their body blocks. + # The start page skips the title page when a title was found. + page_lookup: dict[int, int] = {} + for page in pages: + for block in (page.secondary_slot or []): + page_lookup[id(block)] = page.page_index + + from .heading_detection import find_section_openers as _find_section_openers + title_page_idx = title_winner.page.page_index if title_winner is not None else 0 + section_openers = _find_section_openers(doc, title_page_idx) + + # ----- 10) Extend labeled sections and claim body blocks ------------ + # Each labeled heading keeps its label type; body blocks are marked with + # the used-as-heading flag so heading collection skips claimed caption/section bodies. + # The head block type is preserved; claimed body blocks are not retyped. + caption_regions = build_caption_regions(caption_context) + for caption_region in caption_regions: + head_block = caption_region.primary_slot + head_block.state_slot = head_block.marker_slot + for body_block in caption_region.output_slot: + body_block.measure_slot = True + + # NOTE: References-section detection -- intentionally absent --------- + # Bulk-marking everything after a references heading would hide later + # appendix headings in some documents, so references detection remains off. + # ref = find_references(doc) + # mark_references(doc, ref) + + # NOTE: Ghost-text histogram -- intentionally absent ----------------- + # Recurring text is counted during header/footer/watermark marking. A + # second doc-wide pass would double-count headers and pollute title scoring. + + # ----- 11) Outline assembly and validation gate --------------------- + outline_nodes = assemble_outline(doc, section_openers) + # Validate the assembled outline. Structured outlines must cover enough + # chapters; unstructured outlines are filtered by script and density gap. + if is_outline_valid(doc, outline_nodes): + if not is_chapter_outline_valid(doc, outline_nodes): + outline_nodes = [] + else: + mark_outline_block_types(outline_nodes) + page_count = len(doc.primary_slot) + if doc.secondary_slot.tertiary_slot == 7 or ( + page_count >= 3 + and compute_max_heading_gap(outline_nodes, 1)["max_gap"] > (0.65 if doc.secondary_slot.tertiary_slot == 4 else 0.85) * page_count + ): + outline_nodes = [] + if outline_nodes: + structure = outline_to_dict_tree(outline_nodes, total_pages=len(pages)) + else: + structure = [] + + # ----- 12) Output --------------------------------------------------- + if isinstance(doc_handle, (str, Path)): + doc_name = Path(str(doc_handle)).name + else: + doc_name = "document.pdf" + + return { + "doc_name": doc_name, + "doc_title": doc_title, + "structure": structure, + } + + +__all__ = ["extract_toc", "DocumentState", "find_references", "mark_references"] diff --git a/pageindex/flash/model/__init__.py b/pageindex/flash/model/__init__.py new file mode 100644 index 000000000..06b889a4d --- /dev/null +++ b/pageindex/flash/model/__init__.py @@ -0,0 +1,121 @@ +""" +Data model for rectangles, spans, lines, blocks, character categories, and +alignment predicates. Coordinate convention follows PDF (origin bottom-left, y increases upward). +``Rect`` is constructed as ``Rect(left, right, top, bottom)``. A few internal +storage fields are implementation details; public callers should use the semantic accessors. +""" + +import math +import re +import unicodedata +from decimal import Decimal, ROUND_HALF_UP +from typing import Any, Iterator, Optional, Protocol + +import regex as regex_module # supports Unicode \p{...} property classes + +from .char_stats import ( + _SENTENCE_END_CHARS, + _MINUS_SIGN_CHARS, + _max_nan_propagating, + _min_nan_propagating, + char_category, + is_word_category, + is_punct_category, + _UNICODE_WHITESPACE_CHARS, + _trim_unicode_ws, + _UNICODE_WHITESPACE_CLASS, + _round_half_up_to_int, + CharStats, + merge_char_stats, + letter_count, + punct_count, + info_weight, + is_upper_dominant, +) +from .rects import ( + RectLike, + Rect, + EMPTY_RECT, + Bounded, + rect_union, + rect_intersection, + extend_top_to, + extend_bottom_to, + cmp_left_edge, + left_edge_key, + cmp_reading_order, + reading_order_key, + cmp_bottom_edge, + magnitude_ratio, + same_x_extent, + same_y_extent, + intervals_overlap, + y_overlaps, + left_aligned, + right_aligned, + center_aligned, + x_aligned, + x_centers_close, +) +from .span_line import ( + _bold_font_re, + _italic_font_re, + _font_name_aliases, + _subset_prefix_re, + Span, + Line, + append_span, + last_span, + _HasCharCount, + avg_char_width, + raw_text_of_line, + text_of_line, + avg_char_width2, + _ONE_DECIMAL_QUANTUM, + _format_half_up_one_decimal, + style_key, +) +from .block import ( + Block, + iter_sorted_children, + argmax_key, + last_line_of, + first_span_of, + dominant_style_of, + dominant_font_size, + is_caps_heavy, + is_sentence_like, + heading_score, + case_signal, + alignment_code, + block_text, + deaccented_text, + _COMBINING_MARKS, + _strip_diacritics, +) +from .numbering import ( + _NUMBERING_PREFIX_RE, + _BRACKETED_NUM_RE, + _TO_NUMBER_DEC, + _TO_NUMBER_INF, + _TO_NUMBER_HEX, + _TO_NUMBER_OCT, + _TO_NUMBER_BIN, + to_number, + _detect_numbering, + numbering_text, + numbering_value, + numbering_kind, +) + +__all__ = [ + "RectLike", "Rect", "Bounded", "EMPTY_RECT", "rect_union", "rect_intersection", "extend_top_to", "extend_bottom_to", + "CharStats", "merge_char_stats", "letter_count", "punct_count", "info_weight", "is_upper_dominant", + "char_category", "is_word_category", "is_punct_category", + "Span", "Line", "Block", + "append_span", "last_span", "avg_char_width", "raw_text_of_line", "text_of_line", "avg_char_width2", "style_key", "iter_sorted_children", + "magnitude_ratio", "same_x_extent", "same_y_extent", "intervals_overlap", "y_overlaps", "left_aligned", "right_aligned", "center_aligned", "x_aligned", "x_centers_close", + "to_number", "numbering_text", "numbering_value", "numbering_kind", + "argmax_key", "last_line_of", "first_span_of", "dominant_style_of", "dominant_font_size", "is_caps_heavy", "heading_score", "case_signal", "alignment_code", "block_text", "deaccented_text", + "cmp_left_edge", "left_edge_key", "cmp_reading_order", "reading_order_key", "cmp_bottom_edge", +] diff --git a/pageindex/flash/model/block.py b/pageindex/flash/model/block.py new file mode 100644 index 000000000..b6a9c311a --- /dev/null +++ b/pageindex/flash/model/block.py @@ -0,0 +1,288 @@ +"""Block type with text, style, and alignment helpers.""" + +from __future__ import annotations + +import math +import re +import unicodedata +from typing import Any, Iterator, Optional, Protocol + +from .char_stats import ( + is_punct_category, + CharStats, + merge_char_stats, + letter_count, + punct_count, + info_weight, + is_upper_dominant, +) +from .rects import ( + EMPTY_RECT, + Bounded, + rect_union, + left_aligned, + right_aligned, + center_aligned, +) +from .span_line import ( + Span, + Line, + text_of_line, + style_key, +) + + +class Block(Bounded): + """A vertically contiguous group of lines that share layout, such as a paragraph or heading run. Adding lines maintains weighted style, size, text, bbox, reading-order, classification, and cache fields.""" + + __slots__ = ( + "primary_slot", "char_stats", "alignment_slot", "weighted_ratio_tertiary", "previous_slot", "weighted_skew", "weighted_font_size", "weighted_ratio_primary", "weighted_ratio_secondary", "style_slot", + "style_char_counts", "size_char_counts", "reading_order_index", "orig_index", "type", "isolated_centered", "is_body_paragraph", "measure_slot", "used_as_heading", + "state_slot", "marker_slot", "metric_slot", + "dominant_style_cache", "dominant_size_cache", "token_text_cache", "deaccented_text_cache", "cache_slot", "tokens_cache", + ) + + def __init__(self): + super().__init__(EMPTY_RECT) + self.primary_slot: list = [] + self.char_stats: CharStats = CharStats("") + self.alignment_slot: bool = True + self.weighted_ratio_tertiary: float = 0.0 + self.previous_slot: float = 0.0 + self.weighted_skew: float = 0.0 + self.weighted_font_size: float = 0.0 + self.weighted_ratio_primary: float = 0.0 + self.weighted_ratio_secondary: float = 0.0 + self.style_slot: float = 0.0 + self.style_char_counts: dict = {} + self.size_char_counts: dict = {} + self.reading_order_index: int = 0 + self.orig_index: int = 0 + self.type: int = 0 + self.isolated_centered: bool = False + self.is_body_paragraph: bool = False + self.measure_slot: bool = False + self.used_as_heading: bool = False + self.state_slot: int = 0 + self.marker_slot: int = 0 + self.metric_slot: float = 0.0 + # caches, invalidated on every add_line + self.dominant_style_cache: Optional[str] = None + self.dominant_size_cache: Optional[float] = None + self.token_text_cache: Optional[str] = None + self.deaccented_text_cache: Optional[str] = None + self.cache_slot: Optional[str] = None + self.tokens_cache: Optional[Any] = None + + def __iter__(self): + return iter(self.primary_slot) + + def line_count(self) -> int: + """Line count -- .""" + return len(self.primary_slot) + + def line(self): + """First line -- .""" + return self.primary_slot[0] + + def char_count(self) -> int: # type: ignore[override] + """Total char count across all child lines.""" + return self.char_stats.auxiliary_slot + + def avg_font_size(self) -> float: + """Weighted average font size -- .""" + return self.weighted_font_size + + def bold_frac(self) -> float: + """Weighted bold fraction -- .""" + return self.weighted_ratio_tertiary + + def skew_frac(self) -> float: + """Weighted skew fraction -- .""" + return self.weighted_skew + + def add_line(self, other_line) -> "Block": + """Add a line while maintaining weighted style, size, character, bbox, and per-style histograms.""" + self.alignment_slot = self.alignment_slot and (len(self.primary_slot) <= 0 or center_aligned(self, other_line, 1)) + self.primary_slot.append(other_line) + line = info_weight(self.char_stats) + added_weight = info_weight(other_line.char_stats) + total_weight = line + added_weight + if total_weight > 0: + self.weighted_ratio_tertiary = (self.weighted_ratio_tertiary * line + other_line.bold_frac() * added_weight) / total_weight + self.previous_slot = (self.previous_slot * line + other_line.weighted_ratio_secondary * added_weight) / total_weight + self.weighted_skew = (self.weighted_skew * line + other_line.skew_frac() * added_weight) / total_weight + self.weighted_font_size = (self.weighted_font_size * line + other_line.avg_font_size() * added_weight) / total_weight + self.weighted_ratio_primary = (self.weighted_ratio_primary * line + other_line.cache_slot * added_weight) / total_weight + merge_char_stats(self.char_stats, other_line.char_stats) + if other_line.char_count() <= 0: + return self + line = self.area() # area before union + self.style_slot = max(self.style_slot, other_line.previous_slot) + self.secondary_slot = rect_union(self.secondary_slot, other_line.secondary_slot) + added_weight = self.area() # area after union + if added_weight > 0: + self.weighted_ratio_secondary = (self.weighted_ratio_secondary * line + other_line.cache_slot * other_line.area()) / added_weight + for span in other_line: + sty = style_key(span) + self.style_char_counts[sty] = self.style_char_counts.get(sty, 0) + span.char_count() + # Font-size buckets use half-up rounding to one decimal place. + # Python round is half-to-even, so use floor(x + 0.5) on the + # scaled non-negative font size. + size_key = math.floor(span.font_size * 10 + 0.5) / 10 + self.size_char_counts[size_key] = self.size_char_counts.get(size_key, 0) + span.char_count() + # invalidate caches + self.dominant_style_cache = self.dominant_size_cache = self.token_text_cache = self.deaccented_text_cache = self.cache_slot = self.tokens_cache = None + self.metric_slot = 0.0 + return self + + +# Sorted child iterator. + +def iter_sorted_children(primary_item): + """Iterate a page-like object's sorted children as indexed item records.""" + for idx, item in enumerate(primary_item.secondary_slot): + yield {"index": idx, "block": item} + + +# --------------------------------------------------------------------------- # +# Block-level accessors and derived text/style helpers # +# --------------------------------------------------------------------------- # + + +def argmax_key(items) -> Optional[str]: + """return the key with max value. ``None`` if empty. ``items`` may be a ``dict`` (in which case we iterate ``.items``) or any iterable of ``(key, value)`` pairs. """ + pairs = items.items() if isinstance(items, dict) else items + best: Optional[str] = None + candidate_item = float("-inf") + for reference_item, entry_item in pairs: + if entry_item <= candidate_item: + continue + best = reference_item + candidate_item = entry_item + return best + + +def last_line_of(block: Block) -> Line: + """last child line of a block.""" + return block.primary_slot[-1] + + +def first_span_of(block: Block) -> Span: + """first span of a block's first line.""" + return block.line().primary_slot[0] + + +def dominant_style_of(block: Block) -> str: + """Cached dominant style hash from the block's style histogram.""" + if block.dominant_style_cache is None: + block.dominant_style_cache = argmax_key(block.style_char_counts) or "" + return block.dominant_style_cache + + +def dominant_font_size(block: Block) -> float: + """Return cached dominant font size from rounded-size character counts.""" + if block.dominant_size_cache is None: + block.dominant_size_cache = float(argmax_key(block.size_char_counts) or 0) + return block.dominant_size_cache + + +def is_caps_heavy(primary_item) -> bool: + """Return True if a line or block is uppercase-dominant.""" + return is_upper_dominant(primary_item.char_stats) or primary_item.char_stats.primary_slot[2] >= max(2, primary_item.char_stats.auxiliary_slot) + + +def is_sentence_like(primary_item) -> bool: + """Return whether a block looks like mixed-case body text rather than a heading. The test requires enough tokens, enough uppercase letters, and rejects long lowercase words.""" + from ..tokens import tokenize_block + tokens = tokenize_block(primary_item) + if tokens.length < 3 or is_caps_heavy(primary_item): + return False + upper_count = primary_item.char_stats.primary_slot[2] + if upper_count <= 2 or upper_count < tokens.length / 10: + return False + match = 0 + for token in tokens: + # Skip non-word tokens, short tokens, or g==4 (special) + if token.type != 2 or len(token.str) <= 2 or token.primary_slot == 4: + continue + if token.primary_slot == 2: + match += 1 + elif len(token.str) >= 5: + return False + return match >= 3 + + +def heading_score(heading) -> float: + """Line/block heading score: dominant font size plus caps-heavy and bold bonuses.""" + return dominant_font_size(heading) + (2 if is_caps_heavy(heading) else 0) + (1 if heading.weighted_ratio_tertiary > 0.5 else 0) + + +def case_signal(char_stats: CharStats) -> int: + """Return an uppercase, lowercase, or neutral case signal from character statistics.""" + if is_upper_dominant(char_stats) and not is_punct_category(char_stats.secondary_slot) and letter_count(char_stats) > 3 * char_stats.auxiliary_slot / 4 and punct_count(char_stats) < 5: + return 1 + if char_stats.primary_slot[3] > 0: + return -1 + return 0 + + +def alignment_code(primary_item) -> int: + """cached block-level alignment code. Returns: 1 fully-justified (every line aligned with the block on left or right) 2 left-aligned (every line shares the block's left) 3 flag-set justified (the block center-alignment flag is set) 4 right-aligned 5 mixed / other """ + if primary_item.metric_slot != 0 or len(primary_item.primary_slot) <= 0: + return primary_item.metric_slot + left = True + right = True + any_value = True + for score_value in primary_item.primary_slot: + tolerance = max(1.0, score_value.bbox_width() / 20.0) + line_left_aligned = left_aligned(primary_item, score_value, tolerance) + line_right_aligned = right_aligned(primary_item, score_value, tolerance) + if not line_left_aligned: + left = False + if not line_right_aligned: + right = False + if not (line_left_aligned or line_right_aligned): + any_value = False + if left and not right: + primary_item.metric_slot = 2 + elif right and not left: + primary_item.metric_slot = 4 + elif any_value: + primary_item.metric_slot = 1 + elif primary_item.alignment_slot: + primary_item.metric_slot = 3 + else: + primary_item.metric_slot = 5 + return primary_item.metric_slot + + +def block_text(block: Block) -> str: + """cached joined trimmed text of a block (space-separated).""" + if block.cache_slot is not None: + return block.cache_slot + parts = [] + for line_index, line_value in enumerate(block.primary_slot): + parts.append(text_of_line(line_value)) + if line_index < len(block.primary_slot) - 1: + parts.append(" ") + block.cache_slot = "".join(parts) + return block.cache_slot + + +def deaccented_text(block: Block) -> str: + """Cached diacritic-stripped block text; case and spacing are preserved.""" + if block.deaccented_text_cache is not None: + return block.deaccented_text_cache + block.deaccented_text_cache = _strip_diacritics(block_text(block)) + return block.deaccented_text_cache + + +_COMBINING_MARKS = re.compile("[̀-ͯ]") + + +def _strip_diacritics(text: str) -> str: + """Strip combining diacritics only while preserving case and internal spacing.""" + return unicodedata.normalize( + "NFC", _COMBINING_MARKS.sub("", unicodedata.normalize("NFD", text)) + ) diff --git a/pageindex/flash/model/char_stats.py b/pageindex/flash/model/char_stats.py new file mode 100644 index 000000000..7de1c12af --- /dev/null +++ b/pageindex/flash/model/char_stats.py @@ -0,0 +1,184 @@ +"""Character categories and per-run character statistics.""" + +from __future__ import annotations + +import math +import unicodedata + + +# --------------------------------------------------------------------------- # +# Character classifier # +# --------------------------------------------------------------------------- # + +# Character categories used by tokenization: +# 0 empty +# 1 number (digit / numeral) +# 2 uppercase letter (Lu, Lt) +# 3 lowercase letter (Ll) +# 4 other letter (Lo) -- CJK ideographs, syllabics, etc. +# 5 mark (Mc, Me, Mn) +# 6 sentence-end punct -- . ? ! 。 。 ? ! . +# 7 connector / dash -- _ - — − ⁻ ₋ etc. +# 8 other punctuation +# 9 math symbol (Sm) +# 10 whitespace +# 11 other (symbols, format, control, unassigned) + +_SENTENCE_END_CHARS = frozenset(".?!。。?!.") +_MINUS_SIGN_CHARS = frozenset("−⁻₋") # minus, superscript/subscript minus + + +def _max_nan_propagating(value: float, other_item: float) -> float: + """propagates NaN (Python ``max`` swallows it).""" + if math.isnan(value) or math.isnan(other_item): + return math.nan + return value if value >= other_item else other_item + + +def _min_nan_propagating(value: float, other_item: float) -> float: + """propagates NaN (Python ``min`` swallows it).""" + if math.isnan(value) or math.isnan(other_item): + return math.nan + return value if value <= other_item else other_item + + +def char_category(char_value: str) -> int: + """Return the tokenizer character category code from Unicode General_Category.""" + if not char_value: + return 0 + cat = unicodedata.category(char_value) + # Letters ------------------------------------------------------------------ + if cat == "Ll": + return 3 + if cat == "Lu" or cat == "Lt": + return 2 + if cat == "Lo": + return 4 + # Whitespace --------------------------------------------------------------- + # The whitespace set is the Unicode WhiteSpace + LineTerminator set: + # the C0 set \t\n\v\f\r, the BOM , and + # Unicode Space/Line/Paragraph separators (Zs/Zl/Zp). NOT Python's + # str.isspace, which also matches the C0 separators U+001C-U+001F and NEL + # U+0085, which this tokenizer intentionally excludes, and misses . + if char_value in "\t\n\x0b\x0c\r" or char_value == "\ufeff" or cat in ("Zs", "Zl", "Zp"): + return 10 + # Sentence-end punctuation ------------------------------------------------- + if char_value in _SENTENCE_END_CHARS: + return 6 + # Dash / connector punctuation --------------------------------------------- + if cat in ("Pc", "Pd") or char_value in _MINUS_SIGN_CHARS: + return 7 + # General punctuation ------------------------------------------------------ + if cat.startswith("P"): + return 8 + # Number ------------------------------------------------------------------- + if cat.startswith("N"): + return 1 + # Mark --------------------------------------------------------------------- + if cat.startswith("M"): + return 5 + # Math symbol -------------------------------------------------------------- + if cat == "Sm": + return 9 + return 11 + + +def is_word_category(number: int) -> bool: + """is c a 'word-y' category (letter / digit / mark)?""" + return number == 3 or number == 2 or number == 1 or number == 5 + + +def is_punct_category(number: int) -> bool: + """is c a punctuation-y category (dash / punct / sentence)?""" + return number == 7 or number == 8 or number == 6 + + +# Unicode trim strips the package whitespace set used by text parsing. +# Python str.strip uses a DIFFERENT set: it ALSO strips U+001C-001F and U+0085 +# Trim keeps U+001C..U+001F and strips U+FEFF to match the intended whitespace set. +# (Same set as parser_pdfium_charlevel._UNICODE_WHITESPACE; defined here to avoid a +# circular import -- parser imports from model, not vice-versa.) +_UNICODE_WHITESPACE_CHARS = ( + "\t\n\x0b\x0c\r \xa0 " + "           " + "

   " +) + + +def _trim_unicode_ws(text: str) -> str: + """Strip the package whitespace set, not Python's broader ``str.strip`` set.""" + return text.strip(_UNICODE_WHITESPACE_CHARS) + + +# Unicode-compatible ``\s`` = WhiteSpace + LineTerminator = the same 25-cp set as +# _UNICODE_WHITESPACE_CHARS. Bare Python ``\s`` differs: stdlib ``re`` ``\s`` ALSO matches +# U+001C-U+001F and U+0085, the ``regex`` module ``\s`` matches U+0085, and +# NEITHER matches U+FEFF (which does). Splice this char-class BODY into +# regex definitions ("[" + _UNICODE_WHITESPACE_CLASS + "]") instead of a bare ``\s``. +_UNICODE_WHITESPACE_CLASS = r"\t\n\x0b\x0c\r\x20\xa0  - 

   " + + +def _round_half_up_to_int(value: float) -> int: + """Round a non-negative finite number to an integer using exact half-up semantics. The ``floor(x + 0.5)`` idiom is not equivalent at the single double ``0.49999999999999994``: adding 0.5 rounds up to ``1.0`` so floor gives 1. Compute the fractional part directly (exact for x >= 0 by Sterbenz) and compare to 0.5.""" + score_value = math.floor(value) + frac = value - score_value + if frac < 0.5: + return score_value + return score_value + 1 # frac > 0.5, or an exact 0.5 tie + + +# --------------------------------------------------------------------------- # +# Per-string character-category accumulator # +# --------------------------------------------------------------------------- # + + +class CharStats: + """Collect first/last character category, per-category counts, and total character count.""" + + __slots__ = ("secondary_slot", "tertiary_slot", "primary_slot", "auxiliary_slot") + + def __init__(self, other_text: str): + self.secondary_slot = 0 + self.tertiary_slot = 0 + self.primary_slot = [0] * 12 + self.auxiliary_slot = 0 + for secondary_item in other_text: + cat = char_category(secondary_item) + if self.secondary_slot == 0: + self.secondary_slot = cat + self.tertiary_slot = cat + self.primary_slot[cat] += 1 + self.auxiliary_slot += 1 + + +def merge_char_stats(char_stats: CharStats, other_char_stats: CharStats) -> None: + """merge b into a in place.""" + if char_stats.secondary_slot == 0: + char_stats.secondary_slot = other_char_stats.secondary_slot + if other_char_stats.tertiary_slot != 0: + char_stats.tertiary_slot = other_char_stats.tertiary_slot + for candidate_item in range(12): + char_stats.primary_slot[candidate_item] += other_char_stats.primary_slot[candidate_item] + char_stats.auxiliary_slot += other_char_stats.auxiliary_slot + + +def letter_count(char_stats: CharStats) -> int: + """count of letter-like chars (uppercase + lowercase + other-letter).""" + return char_stats.primary_slot[3] + char_stats.primary_slot[2] + char_stats.primary_slot[4] + + +def punct_count(char_stats: CharStats) -> int: + """count of sentence-punctuation chars (6 + 7 + 8).""" + return char_stats.primary_slot[6] + char_stats.primary_slot[7] + char_stats.primary_slot[8] + + +def info_weight(char_stats: CharStats) -> float: + """'informational' weight. ``letters + 2*other_letter + 0.5*(non-letter)`` -- biases towards alphabetic content; non-letter chars contribute half. """ + return char_stats.primary_slot[3] + char_stats.primary_slot[2] + 2 * char_stats.primary_slot[4] + 0.5 * (char_stats.auxiliary_slot - letter_count(char_stats)) + + +def is_upper_dominant(char_stats: CharStats) -> bool: + """uppercase-dominant string detector. True iff (uppercase chars) > max(letters*3/4, letters-4) and (uppercase chars) > max(3, total/3). """ + secondary_item = char_stats.primary_slot[2] + candidate_item = letter_count(char_stats) + return secondary_item > max(candidate_item * 3 / 4, candidate_item - 4) and secondary_item > max(3, char_stats.auxiliary_slot / 3) diff --git a/pageindex/flash/model/numbering.py b/pageindex/flash/model/numbering.py new file mode 100644 index 000000000..9105de6f5 --- /dev/null +++ b/pageindex/flash/model/numbering.py @@ -0,0 +1,131 @@ +"""Numbering-prefix detection and numeric parsing.""" + +from __future__ import annotations + +import math +import re +import unicodedata + +import regex as regex_module # supports Unicode \p{...} property classes + +from .char_stats import ( + _trim_unicode_ws, + _UNICODE_WHITESPACE_CLASS, +) +from .span_line import ( + Line, + raw_text_of_line, +) + + +# --------------------------------------------------------------------------- # +# Numbering detection # +# --------------------------------------------------------------------------- # + + +# Uses Unicode property classes (\p{Number} / \P{Number}), compiled with the +# ``regex`` module (stdlib ``re`` can't express them). Matches: +# - leading roman or digit (group 1) +# - dotted lowercase a-h (group 2) +# - dotted lowercase ivx (group 3) +_NUMBERING_PREFIX_RE = regex_module.compile( + r"^(?:" + r"([IVX]+|[1-91-9]\p{Number}?)(?:[..。。):]|-\P{Number}|-$|[" + _UNICODE_WHITESPACE_CLASS + r"]|$)" + r"|(?:([A-Ha-h])|([ivx]))[..。。)]" + r")" +) + +# Bracketed numeric labels such as "[1]" or "(1)". +_BRACKETED_NUM_RE = re.compile(r"^[\[\(] *([1-9][0-9]?) *[\)\]]") + + +# string grammar (ToNumber). ASCII digits ONLY: Python's +# ``\d`` and ``float`` both accept Unicode decimal digits (e.g. Arabic-Indic +# ٢) and ``float`` also accepts ``1_000`` / ``inf`` / ``nan``, none of which +# ``Number`` accepts -- hence the explicit ``[0-9]`` classes. +_TO_NUMBER_DEC = re.compile(r"^[+-]?(?:[0-9]+\.?[0-9]*|\.[0-9]+)(?:[eE][+-]?[0-9]+)?$") +_TO_NUMBER_INF = re.compile(r"^[+-]?Infinity$") +_TO_NUMBER_HEX = re.compile(r"^0[xX][0-9a-fA-F]+$") +_TO_NUMBER_OCT = re.compile(r"^0[oO][0-7]+$") +_TO_NUMBER_BIN = re.compile(r"^0[bB][01]+$") + + +def to_number(text: str) -> float: + """NFKC-normalized numeric conversion with decimal, exponent, hex, octal, binary, and Infinity forms.""" + if text is None: + return math.nan + token_value = _trim_unicode_ws(unicodedata.normalize("NFKC", text)) + if token_value == "": + return 0.0 + if _TO_NUMBER_INF.match(token_value): + return -math.inf if token_value[0] == "-" else math.inf + if _TO_NUMBER_HEX.match(token_value): + return float(int(token_value[2:], 16)) + if _TO_NUMBER_OCT.match(token_value): + return float(int(token_value[2:], 8)) + if _TO_NUMBER_BIN.match(token_value): + return float(int(token_value[2:], 2)) + if _TO_NUMBER_DEC.match(token_value): + return float(token_value) + return math.nan + + +def _detect_numbering(line: Line) -> None: + """Detect leading section numbering and cache the numbering kind and text on the line.""" + if line.state_slot != -1: + return # already computed + line.state_slot = 0 + if line.char_count() <= 0: + return + # Drop-capital / large-first-char detection (layout branch). + # If first span is smaller, sits above the next non-empty span, and is + # numeric -> use that span's text as the numbering. + if len(line.primary_slot) > 1: + secondary_item = line.primary_slot[0] + candidate_item = line.primary_slot[2] if (line.primary_slot[1].char_count() <= 0 and len(line.primary_slot) > 2) else line.primary_slot[1] + if ( + secondary_item.bbox_height() < candidate_item.bbox_height() + and secondary_item.bottom_edge() > candidate_item.bottom_edge() + 0.05 * candidate_item.bbox_height() + and not math.isnan(to_number(secondary_item.text)) + ): + line.state_slot = 1 + line.style_slot = secondary_item.text + return + text = raw_text_of_line(line) + measure_item = _NUMBERING_PREFIX_RE.match(text) + if measure_item and measure_item.group(1) and "1" <= measure_item.group(1)[0] <= "9": + line.state_slot = 1 + line.style_slot = measure_item.group(1) + return + if measure_item and (measure_item.group(1) or measure_item.group(3)): + # Roman uppercase (group 1) or other -- both uppercase-ish + line.state_slot = 2 + line.style_slot = measure_item.group(1) or measure_item.group(3) + return + if measure_item and measure_item.group(2): + line.state_slot = 3 + line.style_slot = measure_item.group(2) + return + second_matrix = _BRACKETED_NUM_RE.match(text) + if second_matrix: + line.state_slot = 1 + line.style_slot = second_matrix.group(1) + return + + +def numbering_text(line: Line) -> str: + """get the cached numbering string.""" + _detect_numbering(line) + return line.style_slot + + +def numbering_value(line: Line) -> float: + """get numbering as a number, NaN if non-digit numbering.""" + text = numbering_text(line) + return to_number(text) if line.state_slot == 1 else math.nan + + +def numbering_kind(line: Line) -> int: + """get numbering type (0 none, 1 digit, 2 upper, 3 lower).""" + _detect_numbering(line) + return line.state_slot diff --git a/pageindex/flash/model/rects.py b/pageindex/flash/model/rects.py new file mode 100644 index 000000000..0ac993390 --- /dev/null +++ b/pageindex/flash/model/rects.py @@ -0,0 +1,229 @@ +"""Rectangle types, geometry predicates, and ordering comparators.""" + +from __future__ import annotations + +import math + +from .char_stats import ( + _max_nan_propagating, + _min_nan_propagating, +) + + +# --------------------------------------------------------------------------- # +# Rectangle model # +# --------------------------------------------------------------------------- # + + +class RectLike: + """Empty base for objects that expose bbox accessors.""" + + pass + + +class Rect(RectLike): + """Axis-aligned bbox. PDF coordinates: top > bottom (y increases upward). """ + + __slots__ = ("left", "right", "top", "primary_slot") + + def __init__(self, other_item: float, candidate_item: float, reference_item: float, next_item: float): + self.left = other_item + self.right = candidate_item + self.top = reference_item + self.primary_slot = next_item # bottom + + # --- geometry accessors ---------------------------------- + + def left_edge(self) -> float: return self.left + def right_edge(self) -> float: return self.right + def top_edge(self) -> float: return self.top + def bottom_edge(self) -> float: return self.primary_slot # bottom + def bbox_width(self) -> float: return _max_nan_propagating(0.0, self.right - self.left) # width + def bbox_height(self) -> float: return _max_nan_propagating(0.0, self.top - self.primary_slot) # height + def area(self) -> float: return self.bbox_width() * self.bbox_height() # area + def center_x(self) -> float: return (self.left + self.right) / 2 # x-center + def center_y(self) -> float: return (self.top + self.primary_slot) / 2 # y-center + + def contains(self, other_rect: "Rect") -> bool: + return ( + self.left <= other_rect.left + and self.right >= other_rect.right + and self.top >= other_rect.top + and self.primary_slot <= other_rect.primary_slot + ) + + +# Shared empty / inverted rectangle used to initialize accumulators. +EMPTY_RECT = Rect(math.inf, -math.inf, -math.inf, math.inf) + + +class Bounded(RectLike): + """Mixin-style wrapper around an owned ``Rect``.""" + + __slots__ = ("secondary_slot",) + + def __init__(self, other_rect: Rect): + self.secondary_slot = other_rect + + def left_edge(self) -> float: return self.secondary_slot.left + def right_edge(self) -> float: return self.secondary_slot.right + def top_edge(self) -> float: return self.secondary_slot.top + def bottom_edge(self) -> float: return self.secondary_slot.primary_slot + def bbox_width(self) -> float: return self.secondary_slot.bbox_width() + def bbox_height(self) -> float: return self.secondary_slot.bbox_height() + def area(self) -> float: return self.secondary_slot.area() + def center_x(self) -> float: return self.secondary_slot.center_x() + def center_y(self) -> float: return self.secondary_slot.center_y() + + +def rect_union(rect: Rect, other_rect: Rect) -> Rect: + """bbox union.""" + return Rect( + _min_nan_propagating(rect.left, other_rect.left), + _max_nan_propagating(rect.right, other_rect.right), + _max_nan_propagating(rect.top, other_rect.top), + _min_nan_propagating(rect.primary_slot, other_rect.primary_slot), + ) + + +def rect_intersection(rect: Rect, other_rect: Rect) -> Rect: + """bbox intersection; disjoint boxes may have inverted horizontal or vertical edges.""" + return Rect( + _max_nan_propagating(rect.left, other_rect.left), + _min_nan_propagating(rect.right, other_rect.right), + _min_nan_propagating(rect.top, other_rect.top), + _max_nan_propagating(rect.primary_slot, other_rect.primary_slot), + ) + + +def extend_top_to(rect: Rect, other_item: float) -> Rect: + """Clip the rectangle top to be at least ``other_value``.""" + return Rect(rect.left, rect.right, _max_nan_propagating(rect.top, other_item), rect.primary_slot) + + +def extend_bottom_to(rect: Rect, other_item: float) -> Rect: + """Clip the rectangle bottom to be at most ``other_value``.""" + return Rect(rect.left, rect.right, rect.top, _min_nan_propagating(rect.primary_slot, other_item)) + + +# --------------------------------------------------------------------------- # +# Sort comparators # +# --------------------------------------------------------------------------- # + + +def cmp_left_edge(left_value: Bounded, right_value: Bounded) -> float: + """Order by (left asc, right asc, top desc, bottom desc). Returns the raw delta, not a normalised -1/0/1, because callers only consume the sign.""" + if left_value.left_edge() != right_value.left_edge(): + return left_value.left_edge() - right_value.left_edge() + if left_value.right_edge() != right_value.right_edge(): + return left_value.right_edge() - right_value.right_edge() + if left_value.top_edge() != right_value.top_edge(): + return right_value.top_edge() - left_value.top_edge() + return right_value.bottom_edge() - left_value.bottom_edge() + + +# Python's ``sorted`` accepts a key, not a cmp. Provide key functions too. +def left_edge_key(primary_item: Bounded) -> tuple: + return (primary_item.left_edge(), primary_item.right_edge(), -primary_item.top_edge(), -primary_item.bottom_edge()) + + +def cmp_reading_order(left_value: Bounded, right_value: Bounded) -> float: + """Order by (top desc, bottom desc, left asc, right asc). Top-of-page rows come first; within a row, leftmost first. Returns the raw delta because callers only consume the sign.""" + if left_value.top_edge() != right_value.top_edge(): + return right_value.top_edge() - left_value.top_edge() + if left_value.bottom_edge() != right_value.bottom_edge(): + return right_value.bottom_edge() - left_value.bottom_edge() + if left_value.left_edge() != right_value.left_edge(): + return left_value.left_edge() - right_value.left_edge() + return left_value.right_edge() - right_value.right_edge() + + +def reading_order_key(primary_item: Bounded) -> tuple: + return (-primary_item.top_edge(), -primary_item.bottom_edge(), primary_item.left_edge(), primary_item.right_edge()) + + +def cmp_bottom_edge(left_value: Bounded, right_value: Bounded) -> float: + """Order by (bottom asc, top asc, left asc, right asc). Returns the raw delta because callers only consume the sign.""" + if left_value.bottom_edge() != right_value.bottom_edge(): + return left_value.bottom_edge() - right_value.bottom_edge() + if left_value.top_edge() != right_value.top_edge(): + return left_value.top_edge() - right_value.top_edge() + if left_value.left_edge() != right_value.left_edge(): + return left_value.left_edge() - right_value.left_edge() + return left_value.right_edge() - right_value.right_edge() + + +# --------------------------------------------------------------------------- # +# Alignment / overlap predicates # +# --------------------------------------------------------------------------- # + + +def magnitude_ratio(value: float, other_item: float) -> float: + """Return the larger-magnitude-over-smaller-magnitude ratio with IEEE-754 division semantics. Division by zero yields +/-Infinity for a nonzero non-NaN numerator and NaN for +/-0 over +/-0 and NaN over +/-0. Downstream threshold tests rely on signed infinity, so divide-by-zero must not be collapsed to NaN. """ + # NaN comparisons take the false arm, which selects ``other_value / value``. + if abs(value) > abs(other_item): + num, den = value, other_item + else: + num, den = other_item, value + # raw `num/den`. Python raises ZeroDivisionError on den == +/-0, so the + # IEEE cases are spelled out: x/±0 = ±Infinity with sign(x) XOR sign(±0) + # 5/-0 = -Infinity, ±0/±0 = NaN, NaN/±0 = NaN. A NaN denominator passes + # `den != 0` and divides through to NaN. + if den != 0: + return num / den + if num == 0 or math.isnan(num): + return math.nan + return math.copysign(math.inf, num) * math.copysign(1.0, den) + + +def same_x_extent(primary_item: Bounded, secondary_item: Bounded, candidate_item: float) -> bool: + """Return whether both horizontal edges are within the tolerance.""" + return abs(primary_item.left_edge() - secondary_item.left_edge()) <= candidate_item and abs(primary_item.right_edge() - secondary_item.right_edge()) <= candidate_item + + +def same_y_extent(primary_item: Bounded, secondary_item: Bounded, candidate_item: float) -> bool: + """Return whether both vertical edges are within the tolerance.""" + return abs(primary_item.top_edge() - secondary_item.top_edge()) <= candidate_item and abs(primary_item.bottom_edge() - secondary_item.bottom_edge()) <= candidate_item + + +def intervals_overlap(value: float, other_item: float, candidate_item: float, reference_item: float) -> bool: + """Return whether the two closed ranges overlap by either endpoint.""" + return (value <= candidate_item and candidate_item <= other_item) or (candidate_item <= value and value <= reference_item) + + +def y_overlaps(primary_item: Bounded, secondary_item: Bounded) -> bool: + """Return whether the vertical intervals of two boxes overlap.""" + return intervals_overlap(primary_item.bottom_edge(), primary_item.top_edge(), secondary_item.bottom_edge(), secondary_item.top_edge()) + + +def left_aligned(primary_item: Bounded, secondary_item: Bounded, candidate_item: float) -> bool: + """Return whether left edges match within the tolerance.""" + return abs(primary_item.left_edge() - secondary_item.left_edge()) <= candidate_item + + +def right_aligned(primary_item: Bounded, secondary_item: Bounded, candidate_item: float) -> bool: + """Return whether right edges match within the tolerance.""" + return abs(primary_item.right_edge() - secondary_item.right_edge()) <= candidate_item + + +def center_aligned(primary_item: Bounded, secondary_item: Bounded, candidate_item: float) -> bool: + """Return whether two boxes are center-aligned within the tolerance. Their left and right edge offsets must have opposite signs, then pass the center-distance tolerance.""" + reference_item = primary_item.left_edge() - secondary_item.left_edge() + entry_item = primary_item.right_edge() - secondary_item.right_edge() + def sign(signed_delta): + if signed_delta > 0: return 1 + if signed_delta < 0: return -1 + return 0 + if sign(reference_item) != -sign(entry_item): + return False + return abs(primary_item.center_x() - secondary_item.center_x()) <= max(candidate_item, min(abs(reference_item), abs(entry_item)) / 2) + + +def x_aligned(primary_item: Bounded, secondary_item: Bounded, candidate_item: float) -> bool: + """any of left / right / center aligned.""" + return left_aligned(primary_item, secondary_item, candidate_item) or right_aligned(primary_item, secondary_item, candidate_item) or center_aligned(primary_item, secondary_item, candidate_item) + + +def x_centers_close(primary_item: Bounded, secondary_item: Bounded) -> bool: + """Return whether x-centers match within the secondary box width tolerance.""" + return abs(secondary_item.center_x() - primary_item.center_x()) <= max(1, secondary_item.bbox_width() / 10) diff --git a/pageindex/flash/model/span_line.py b/pageindex/flash/model/span_line.py new file mode 100644 index 000000000..50b83215d --- /dev/null +++ b/pageindex/flash/model/span_line.py @@ -0,0 +1,224 @@ +"""Span and Line types with text and style helpers.""" + +from __future__ import annotations + +import re +from decimal import Decimal, ROUND_HALF_UP +from typing import Any, Iterator, Optional, Protocol + +from .char_stats import ( + _trim_unicode_ws, + CharStats, + merge_char_stats, + letter_count, + info_weight, +) +from .rects import ( + Rect, + EMPTY_RECT, + Bounded, + rect_union, +) + + +# --------------------------------------------------------------------------- # +# Text span # +# --------------------------------------------------------------------------- # + + +# Font-style detectors. +_bold_font_re = re.compile(r"(bold|timesb)", re.IGNORECASE) +_italic_font_re = re.compile(r"(ital|it$|i[1-9][0-9]*$|obliq)", re.IGNORECASE) +# Font-name canonicalization map. +_font_name_aliases = { + "timesnewroman": "Times", + "times-new-roman": "Times", + "timesroman": "Times", + "times-roman": "Times", + "timesnew": "Times", + "times-new": "Times", +} + +# subset prefix regex: 6 uppercase letters + plus sign +_subset_prefix_re = re.compile(r"^[A-Z]{6}\+") + + +class Span(Bounded): + """Span emitted by one text-showing item. Stores raw and trimmed text, character statistics, skew, font family/name, font size, bold/italic flags, and bbox helpers.""" + + __slots__ = ( + "text", "state_slot", "char_stats", "previous_slot", "font_family", "font_name", "font_size", "primary_slot", "measure_slot", + ) + + def __init__( + self, + bbox: Rect, + text: str, + font_name_raw: str, + font_size: float, + bold: bool, + italic: bool, + skew: float = 0.0, + font_family: str = "", + ): + """Create a span from parser-normalized text, font, style, skew, and bounding-box fields.""" + super().__init__(bbox) + self.text = text + self.state_slot = _trim_unicode_ws(text) + self.char_stats = CharStats(self.state_slot) + self.previous_slot = skew + self.font_family = font_family + + # ---- font name normalisation ----------- + reference_item = font_name_raw + if _subset_prefix_re.match(reference_item): + reference_item = reference_item[7:] + reference_item = _font_name_aliases.get(reference_item.lower(), reference_item) + + self.font_name = reference_item + + self.font_size = font_size + + # Bold comes from the adapter flag or from the normalized font name. + self.primary_slot = bool(bold) or bool(_bold_font_re.search(self.font_name)) + # Italic is name-derived only. The ``italic`` parameter is accepted + # for adapter compatibility but is not consulted. + del italic # noqa: F841 -- explicitly drop the arg + self.measure_slot = bool(_italic_font_re.search(self.font_name)) + + def char_count(self) -> int: # type: ignore[override] + """Span char count.""" + return self.char_stats.auxiliary_slot + + def font_style(self) -> str: + """" B" or " R".""" + return f"{self.font_name} {'B' if self.primary_slot else 'R'}" + + +# --------------------------------------------------------------------------- # +# Text line # +# --------------------------------------------------------------------------- # + + +class Line(Bounded): + """A list of spans on roughly the same baseline, with line-wide character statistics, first letter-bearing span, weighted bold/italic/skew/font-size aggregates, cached text, numbering state, column index, and span list.""" + + __slots__ = ( + "primary_slot", "char_stats", "alignment_slot", "weighted_ratio_primary", "weighted_ratio_secondary", "weighted_ratio_tertiary", "metric_slot", "previous_slot", "measure_slot", "marker_slot", "state_slot", "style_slot", "cache_slot", + ) + + def __init__(self): + super().__init__(EMPTY_RECT) + self.primary_slot: list[Span] = [] + self.char_stats: CharStats = CharStats("") + self.alignment_slot: Optional[Span] = None + self.weighted_ratio_primary: float = 0.0 + self.weighted_ratio_secondary: float = 0.0 + + self.weighted_ratio_tertiary: float = 0.0 + self.metric_slot: float = 0.0 + self.previous_slot: float = 0.0 + # Column index assigned by the column pass; -1 means unassigned. + self.measure_slot: int = -1 + self.marker_slot: Optional[str] = None + self.state_slot: int = -1 + self.style_slot: str = "" + self.cache_slot: float = 0.0 + + def __iter__(self) -> Iterator[Span]: + return iter(self.primary_slot) + + def char_count(self) -> int: # type: ignore[override] + return self.char_stats.auxiliary_slot + + def avg_font_size(self) -> float: + return self.metric_slot + + def bold_frac(self) -> float: + return self.weighted_ratio_primary + + def skew_frac(self) -> float: + return self.weighted_ratio_tertiary + + +def append_span(line: Line, other_span: Span) -> Line: + """append span b into line a, updating weighted fields. Every aggregate field is updated in one pass so downstream line scoring sees the same weighted style, size, and geometry summaries. """ + line.primary_slot.append(other_span) + span = info_weight(line.char_stats) + added_weight = info_weight(other_span.char_stats) + total_weight = span + added_weight + if total_weight > 0: + line.weighted_ratio_primary = (line.weighted_ratio_primary * span + (1 if other_span.primary_slot else 0) * added_weight) / total_weight + line.weighted_ratio_secondary = (line.weighted_ratio_secondary * span + (1 if other_span.measure_slot else 0) * added_weight) / total_weight + line.weighted_ratio_tertiary = (line.weighted_ratio_tertiary * span + other_span.previous_slot * added_weight) / total_weight + line.metric_slot = (line.metric_slot * span + other_span.font_size * added_weight) / total_weight + merge_char_stats(line.char_stats, other_span.char_stats) + if line.alignment_slot is None and letter_count(other_span.char_stats) > 0: + line.alignment_slot = other_span + if other_span.char_count() <= 0: + return line + span = line.area() + line.previous_slot = max(line.previous_slot, other_span.bbox_height()) + line.secondary_slot = rect_union(line.secondary_slot, other_span.secondary_slot) + line.cache_slot = min(1.0, (line.cache_slot * span + other_span.area()) / max(1.0, line.area())) + line.marker_slot = None + line.state_slot = -1 + line.style_slot = "" + return line + + +def last_span(line: Line) -> Span: + """last span of line.""" + return line.primary_slot[-1] + + +class _HasCharCount(Protocol): + """Anything with K (char count), A (width), N (height).""" + + def char_count(self) -> int: ... + def bbox_width(self) -> float: ... + def bbox_height(self) -> float: ... + + +def avg_char_width(primary_item: _HasCharCount) -> float: + """Width per character. Returns 0 if there are no characters.""" + return 0.0 if primary_item.char_count() <= 0 else primary_item.bbox_width() / primary_item.char_count() + + +def raw_text_of_line(line: Line) -> str: + """concatenate raw text of all spans (no trimming).""" + parts = [] + for span in line.primary_slot: + parts.append(span.text) + return "".join(parts) + + +def text_of_line(line: Line) -> str: + """cached trimmed line text.""" + if line.marker_slot is not None: + return line.marker_slot + line.marker_slot = _trim_unicode_ws(raw_text_of_line(line)) + return line.marker_slot + + +def avg_char_width2(primary_item: _HasCharCount) -> float: + """Width per character. Returns 0 for empty text.""" + return 0.0 if primary_item.char_count() <= 0 else primary_item.bbox_width() / primary_item.char_count() + + +# --------------------------------------------------------------------------- # +# Text block # +# --------------------------------------------------------------------------- # + + +_ONE_DECIMAL_QUANTUM = Decimal("0.1") + + +def _format_half_up_one_decimal(value: float) -> str: + """Round the exact double half-away-from-zero; the stats module uses the same helper.""" + return str(Decimal(value).quantize(_ONE_DECIMAL_QUANTUM, rounding=ROUND_HALF_UP)) + + +def style_key(span: "Span") -> str: + """Style hash ``" "`` using shared half-up rounding.""" + return f"{span.font_style()} {_format_half_up_one_decimal(span.font_size)}" diff --git a/pageindex/flash/outline/__init__.py b/pageindex/flash/outline/__init__.py new file mode 100644 index 000000000..e9d5ca0da --- /dev/null +++ b/pageindex/flash/outline/__init__.py @@ -0,0 +1,54 @@ +"""Heading predicates and section-keyword helpers. The full outline tree is assembled in ``outline_assembly``. This module keeps +the lower-level heading checks that decide whether a block is a plausible +outline heading based on numbering, style, geometry, and section-keyword tries. +""" + +import re +from collections import defaultdict +from typing import Optional + +from ..labels import extract_structural_number +from ..model import numbering_text, numbering_kind, block_text, is_caps_heavy, Block +from ..stats import column_index_of +from ..tokens import set_case_fold, TrieConfig, build_trie, tokenize_block, trie_full_match + +from .filtering import ( + SECTION_KEYWORD_TRIE, + heading_order_key, + _NON_HEADING_TYPES, + _DOT_LEADER_RE, + _CAPTION_LABEL_RE, + _EQUATION_LABEL_RE, + _PAREN_FRAGMENT_RE, + _looks_like_pseudo_code, + is_heading_candidate, + _style_key, + _numbering_depth, + collect_headings, + _heading_signature, + _matches_section_keywords, + _PSEUDO_CODE_PATTERNS, + _AUTHOR_PATTERNS, + _BULLET_LIST_RE, + filter_by_clique, +) +from .tree import ( + extract_top_level_headings, + assign_levels, + _heading_title, + _heading_page_num, + build_tree, + validate, +) + +__all__ = [ + "is_heading_candidate", + "collect_headings", + "filter_by_clique", + "assign_levels", + "build_tree", + "validate", + "extract_top_level_headings", + "SECTION_KEYWORD_TRIE", + "heading_order_key", +] diff --git a/pageindex/flash/outline/filtering.py b/pageindex/flash/outline/filtering.py new file mode 100644 index 000000000..3bc26e06c --- /dev/null +++ b/pageindex/flash/outline/filtering.py @@ -0,0 +1,241 @@ +"""Heading candidate collection, filtering, and keyword screening.""" + +from __future__ import annotations + +import re +from collections import defaultdict +from typing import Optional + +from ..labels import extract_structural_number +from ..model import numbering_text, numbering_kind, block_text, is_caps_heavy, Block +from ..stats import column_index_of +from ..tokens import set_case_fold, TrieConfig, build_trie, tokenize_block, trie_full_match + + +# English section keywords loaded into a case-folded trie matching tokenized +# block text exactly. +SECTION_KEYWORD_TRIE = build_trie( + [ + "acknowledgements", "acknowledgments", + "background", + "conclusion", "conclusions", + "discussion", + "introduction", + "materials and methods", + "method", "methods", + "results", + ], + set_case_fold(TrieConfig(), True), +) + + +def heading_order_key(block: Block, page_lookup: dict[int, int]) -> tuple: + """Sort by page, then column index and reading position.""" + return ( + page_lookup.get(id(block), 1), + column_index_of(block), + -block.top_edge(), + -block.bottom_edge(), + block.left_edge(), + block.right_edge(), + ) + + +# --------------------------------------------------------------------------- # +# Heading candidate gates # +# --------------------------------------------------------------------------- # + + +# Block types excluded from heading candidacy: +# 1 header, 2 footer, 3 references body, 9 TOC page content, +# 12 watermark/caption, 13 claimed labeled-section body, 99 title. +_NON_HEADING_TYPES = frozenset({1, 2, 3, 9, 12, 13, 99}) + +_DOT_LEADER_RE = re.compile(r"\.{4,}\s*\d+\s*$") +_CAPTION_LABEL_RE = re.compile( + r"^\s*(?:figure|fig\.?|table|tab\.?|algorithm|alg\.?|equation|eq\.?|listing)\s+\d", + re.IGNORECASE, +) +# Equation labels like "(1)", "(2.3)", "(a)", "(i)", "(*)" -- parenthesised +# short labels that the numbering detector mistakes for "1." section starts. +_EQUATION_LABEL_RE = re.compile( + r"^\s*[\[\(]\s*(?:[0-9]+(?:\.\d+)?[a-z]?|[a-z]|[ivx]+)\s*[\)\]]\s*$", + re.IGNORECASE, +) +# Parenthesised body fragments like "(current cost)", "(estimated cost)". +_PAREN_FRAGMENT_RE = re.compile(r"^\s*[\[\(][^\]\)]{1,40}[\]\)]\s*$") + + +def _looks_like_pseudo_code(text: str) -> bool: + """Reject pseudo-code and math fragments that can resemble numbered headings.""" + if any(pat.search(text) for pat in _PSEUDO_CODE_PATTERNS): + return True + # No alphabetic word of >= 3 letters? Reject. + if not re.search(r"[A-Za-zÀ-ÿ一-鿿가-힯]{3,}", text): + return True + # Bullet-list item: "1. long flowing prose..." + if _BULLET_LIST_RE.match(text) and len(text) > 80: + return True + # Parenthesised fragment: "(current cost)", "(maximum flow)" + if _PAREN_FRAGMENT_RE.match(text): + return True + # Author-block heuristics + if any(pat.search(text) for pat in _AUTHOR_PATTERNS): + return True + return False + + +def is_heading_candidate(block: Block, body_size: float, body_bold: bool) -> bool: + """Return whether a block has the visual and textual shape of a heading.""" + if block.type in _NON_HEADING_TYPES: + return False + if block.line_count() > 6: + return False + text = block_text(block).strip() + if len(text) < 2 or len(text) > 200: + return False + if _DOT_LEADER_RE.search(text): + return False + if _CAPTION_LABEL_RE.match(text): + return False + if _EQUATION_LABEL_RE.match(text): + return False + if _looks_like_pseudo_code(text): + return False + marker_type = getattr(block, "marker_slot", 0) + if marker_type == 4: + return True + block_size = block.avg_font_size() or body_size + size_gain = block_size / max(body_size, 1e-3) + if size_gain >= 1.08: + return True + if block.bold_frac() > 0.5 and not body_bold and size_gain >= 0.95: + return True + if block.line_count() >= 1 and numbering_kind(block.line()) != 0 and len(text) <= 120 and ( + block.bold_frac() > 0.3 or size_gain >= 1.0 + ): + return True + if is_caps_heavy(block) and len(text) <= 80 and size_gain >= 1.0: + return True + return False + + +# --------------------------------------------------------------------------- # +# Style buckets + level assignment # +# --------------------------------------------------------------------------- # + + +def _style_key(block: Block) -> tuple[str, float, bool]: + """Hashable signature for grouping headings into hierarchy levels.""" + first_span = block.line().primary_slot[0] if block.line().primary_slot else None + font = first_span.font_name if first_span else "" + return (font, round(block.avg_font_size(), 1), block.bold_frac() > 0.5) + + +def _numbering_depth(block: Block) -> Optional[int]: + """Return the section-numbering depth, e.g. ``1.2.3-> 3. None if the block doesn't start with a digit-style number (only digit chains use ``.``-separated depth; Roman / letter labels return 1). """ + if numbering_kind(block.line()) != 1: + return None + text = numbering_text(block.line()) + if not text: + return None + if re.match(r"^\d+(?:\.\d+)*$", text): + return text.count(".") + 1 + return 1 + + +def collect_headings(doc) -> list[Block]: + """Walk all pages, gather heading-candidate blocks in reading order.""" + body_size = doc.secondary_slot.primary_slot + body_bold = doc.secondary_slot.tertiary_slot == 0 + out: list[Block] = [] + for page in doc.primary_slot: + for block in (page.secondary_slot or []): + if is_heading_candidate(block, body_size, body_bold): + out.append(block) + return out + + +# --------------------------------------------------------------------------- # +# Clique selection # +# --------------------------------------------------------------------------- # + + +def _heading_signature(block: Block) -> str: + """Return the first visible span's font style for a heading block.""" + if not block.primary_slot or not block.primary_slot[0].primary_slot: + return "" + return block.primary_slot[0].primary_slot[0].font_style() + + +def _matches_section_keywords(block: Block) -> bool: + """Full-match canonical English section names after stripping a leading structural number. For example, "1 Introduction" tokenizes as ["1", "Introduction"], and the numeric prefix must be removed before keyword matching.""" + tokens = tokenize_block(block) + prefix = extract_structural_number(tokens) + if prefix is not None: + tokens = tokens.slice(prefix.length) + return trie_full_match(SECTION_KEYWORD_TRIE, tokens) + + +_PSEUDO_CODE_PATTERNS = ( + re.compile(r"^\s*\d+\s*:"), # "2:" / "10:" lead -> pseudo-code step + re.compile(r"[∀-⋿←-⇿≤≥≠∈∉∂∇∑∏√]"), # math operators + re.compile(r"^\s*\d+[a-z]"), # "9else", "15return" (no space) + re.compile(r"^[\d.\s/]+$"), # pure numbers / decimals + re.compile(r"^\s*\d+\s*[+\-*/=]\s*\d"), # arithmetic + re.compile(r"^\s*[a-z]+\s*[+\-*/=]\s*"), # variable assignments + re.compile(r"\bwhile\b|\bif\b|\belse\b|\bfor\b|\breturn\b|\bdo\b", re.IGNORECASE), # code keywords + # Subfigure captions like "(a) RETINA", "(b) IRMA", "(i) plot" + re.compile(r"^\s*[\(\[]\s*[a-zivx]+\s*[\)\]]\s+\w"), +) + +# Author block / affiliation patterns: +# * "Yu Tang†, Leong Hou U‡, ..." -- multiple comma-separated names with +# affiliation markers +# * "Kimi Team" -- short "X Team" / "X Lab" / "X Group" naming +# * "†The University of ..." -- starts with affiliation marker +# * "{user, another}@domain" -- email block +_AUTHOR_PATTERNS = ( + re.compile(r"[†‡§¶∗*]"), # affiliation markers + re.compile(r"@\S+\."), # contains email + re.compile(r"^\s*\S+\s+(?:Team|Group|Lab|Labs|Inc\.|Corp\.|Co\.)\s*$"), +) +# Bullet-list items: "1. " followed by long flowing text (>80 chars total) +_BULLET_LIST_RE = re.compile(r"^\s*\d+\.\s+\w") + + +def filter_by_clique(headings: list[Block]) -> list[Block]: + """Keep headings that match the dominant section-keyword style group.""" + if not headings: + return headings + anchor_groups: dict[str, list[Block]] = defaultdict(list) + for state_item in headings: + if _matches_section_keywords(state_item): + anchor_groups[_heading_signature(state_item)].append(state_item) + if not anchor_groups: + return headings + winner_sig, winner_group = max(anchor_groups.items(), key=lambda item_pair: len(item_pair[1])) + if len(winner_group) <= 1: + return headings + out: list[Block] = [] + for state_item in headings: + if _heading_signature(state_item) == winner_sig: + out.append(state_item) + continue + # Different font from heading clique. Only keep if it's a clearly + # numbered heading that doesn't smell of pseudo-code / math. + if numbering_kind(state_item.line()) != 1: + continue + numbering = numbering_text(state_item.line()) + if not re.match(r"^\d+(?:\.\d+){0,2}$", numbering): + continue + text = block_text(state_item) + if any(pat.search(text) for pat in _PSEUDO_CODE_PATTERNS): + continue + # Also require: at least one alphabetic word AFTER the number + # ("2 Introduction" yes, "9else" no, "1.804 1.737 1.692" no) + after_num = re.sub(r"^\s*\d+(?:\.\d+){0,2}\s*[.:)]?\s*", "", text) + if not re.search(r"[A-Za-zÀ-ÿ一-鿿가-힯]{3,}", after_num): + continue + out.append(state_item) + return out diff --git a/pageindex/flash/outline/tree.py b/pageindex/flash/outline/tree.py new file mode 100644 index 000000000..2c41136af --- /dev/null +++ b/pageindex/flash/outline/tree.py @@ -0,0 +1,132 @@ +"""Level assignment and outline tree construction.""" + +from __future__ import annotations + +import re +from collections import defaultdict +from ..model import numbering_text, numbering_kind, block_text, is_caps_heavy, Block + +from .filtering import ( + _style_key, + _numbering_depth, +) + + +def extract_top_level_headings(headings: list[Block], levels: dict[int, int]) -> list[Block]: + """Flatten the heading tree, returning only top-level headings.""" + return [heading for heading in headings if levels.get(id(heading), 6) <= 1] + + +def assign_levels(headings: list[Block]) -> dict[int, int]: + """Return ``{id(block) -> level}``. 1. Bucket by style key. 2. Rank styles by (size DESC, bold DESC) and assign level 1..6 in that order (anything below the 6th distinct style is clamped to 6). 3. If a heading has digit-numbering, its level is overridden to min(numbering_depth, style_level) -- numbering wins for deeper grouping but never promotes a heading above its style rank. """ + buckets: dict[tuple[str, float, bool], list[Block]] = defaultdict(list) + for state_item in headings: + buckets[_style_key(state_item)].append(state_item) + ranked = sorted(buckets.keys(), key=lambda key_value: (-key_value[1], not key_value[2])) + style_level = {key_value: min(index_value + 1, 6) for index_value, key_value in enumerate(ranked)} + out: dict[int, int] = {} + for state_item in headings: + lvl = style_level.get(_style_key(state_item), 6) + depth = _numbering_depth(state_item) + if depth is not None: + lvl = max(1, min(lvl, depth)) + out[id(state_item)] = lvl + return out + + +# --------------------------------------------------------------------------- # +# Tree assembly # +# --------------------------------------------------------------------------- # + + +def _heading_title(block: Block) -> str: + """Cleaned title text for output (no dot leaders, single-line).""" + text = block_text(block).strip() + text = re.sub(r"\s+", " ", text) + return text + + +def _heading_page_num(block: Block, page_lookup) -> int: + """Find the 1-based page number that owns this block. ``page_lookup`` is a dict ``{id(block) -> page.u}`` precomputed by the caller for O(1) lookup. """ + return page_lookup.get(id(block), 1) + + +def build_tree(headings: list[Block], levels: dict[int, int], page_lookup, total_pages: int) -> list[dict]: + """Assemble nested ``{title, start_index, end_index, nodes}`` tree.""" + if not headings: + return [] + + root: list[dict] = [] + stack: list[tuple[int, dict]] = [] + for state_item in headings: + title = _heading_title(state_item) + if not title: + continue + node = { + "title": title, + "start_index": _heading_page_num(state_item, page_lookup), + "end_index": _heading_page_num(state_item, page_lookup), + "nodes": [], + } + lvl = levels.get(id(state_item), 6) + while stack and stack[-1][0] >= lvl: + stack.pop() + if not stack: + root.append(node) + else: + stack[-1][1]["nodes"].append(node) + stack.append((lvl, node)) + + # Fill end_index in DFS order. + flat: list[dict] = [] + + def _walk_nodes(nodes: list[dict]) -> None: + for count_item in nodes: + flat.append(count_item) + _walk_nodes(count_item["nodes"]) + + _walk_nodes(root) + for index_value, count_item in enumerate(flat): + next_start = flat[index_value + 1]["start_index"] if index_value + 1 < len(flat) else total_pages + count_item["end_index"] = max(count_item["start_index"], next_start - 1 if next_start > count_item["start_index"] else count_item["start_index"]) + if flat: + flat[-1]["end_index"] = max(flat[-1]["start_index"], total_pages) + + # Drop empty children so the JSON matches the shape the rest of PageIndex emits. + def _drop_empty_children(nodes: list[dict]) -> list[dict]: + for count_item in nodes: + if count_item["nodes"]: + _drop_empty_children(count_item["nodes"]) + else: + del count_item["nodes"] + return nodes + + return _drop_empty_children(root) + + +# --------------------------------------------------------------------------- # +# Outline validation # +# --------------------------------------------------------------------------- # + + +def validate(headings: list[Block], levels: dict[int, int], doc) -> bool: + """Return whether the outline has enough top-level headings spanning a meaningful fraction of the document.""" + top = [state_item for state_item in headings if levels.get(id(state_item), 6) <= 2] + if len(top) < 3: + return False + if len(top) >= 5: + return True + last_page = 1 + for state_item in top: + # Direct page lookup would need a page back-reference; we use the document + # order proxy (top is already in reading order). + # Find by scanning document pages for the page containing the block. + page_num = 1 + for page in doc.primary_slot: + if state_item in (page.secondary_slot or []): + page_num = page.page_index + break + if page_num - last_page > 0.5 * len(doc.primary_slot): + return False + last_page = page_num + return True diff --git a/pageindex/flash/outline_assembly/__init__.py b/pageindex/flash/outline_assembly/__init__.py new file mode 100644 index 000000000..303c14883 --- /dev/null +++ b/pageindex/flash/outline_assembly/__init__.py @@ -0,0 +1,110 @@ +"""Outline assembly chain. This module turns heading candidates and labeled section regions into the final +nested outline tree. It groups candidates by numbering depth, style signature, +script compatibility, document order, and local clusters, then serializes the +tree into the public PageIndex JSON shape. +""" + +import math +from typing import Any, Callable, Optional + +from sortedcontainers import SortedKeyList +from ..model import ( + style_key, left_aligned, right_aligned, center_aligned, x_aligned, rect_union, + Rect, last_span, avg_char_width, raw_text_of_line, heading_score, numbering_text, numbering_value, numbering_kind, + reading_order_key, left_edge_key, _trim_unicode_ws, _round_half_up_to_int, Line, last_line_of, first_span_of, block_text, deaccented_text, letter_count, dominant_style_of, info_weight, dominant_font_size, is_upper_dominant, is_caps_heavy, alignment_code, Block, +) +from ..stats import style_key as style_key_fn, column_index_of, tally_scripts, dominant_script_family, ScriptHistogram +from ..tokens import ( + Token, TokenView, wrap_tokens, enumerate_tokens, last_token, trie_prefix_match, first_token, set_case_fold, TrieConfig, build_trie, tokenize_block, avg_char_width as avg_char_width_fn, trie_full_match, first_anchor_span, is_char_token, is_word_token, +) + + +# --------------------------------------------------------------------------- # +# Numbering-pattern clique selection. +# --------------------------------------------------------------------------- # + + +# Section-keyword trie shared with outline filtering. +from ..outline import SECTION_KEYWORD_TRIE + +from .candidates import ( + _viewport_y_fraction, + HeadingCandidate, + OutlineNode, + compare_heading_order, + _compare_block_order, + heading_order_key, + is_script_compatible, + heading_signature, + parent_signature, + cached_signature, + is_in_oo_range, + has_style_neighbor, +) +from .style_context import ( + StyleCluster, + pick_style_bucket, + has_conflict_in_context, + is_compatible_with_context, + OutlineContext, + NumberingTrie, + insert_numbering, + count_sibling_numberings, + OutlineState, + _apply_heading_to_state, + compare_heading_depth, +) +from .cliques import ( + find_keyword_clique, + CliqueTreeNode, + find_ancestor_next_sibling, + descend_to_deepest_last, + append_tree_child, + CliqueTreeBuilder, + block_style_signature, + is_member_of_tree, + can_share_heading_style, + compare_block_order, + heading_precedes_line, + CliqueFilterContext, + detect_body_headings, + partition_candidates, + interleave_clusters, +) +from .selection import ( + min_font_distance, + should_reject_heading, + push_heading_to_state, + HierarchyStack, + find_parent_heading, + is_appendix_nesting_ok, + extract_sub_headings, + extract_top_level_headings, + is_outline_valid, + is_chapter_outline_valid, +) +from .assembly import ( + mark_outline_block_types, + compute_max_heading_gap, + has_table_or_prominent, + is_landscape_or_empty, + build_heading_from_block, + assemble_outline, + _flatten_outline_nodes, + _heading_appears_at_page_top, + outline_to_dict_tree, +) + +__all__ = [ + "HeadingCandidate", "OutlineNode", + "compare_heading_order", "heading_order_key", "compare_heading_depth", + "is_script_compatible", "heading_signature", "parent_signature", "cached_signature", "is_in_oo_range", "has_style_neighbor", "pick_style_bucket", "has_conflict_in_context", "is_compatible_with_context", + "StyleCluster", "OutlineContext", "NumberingTrie", "insert_numbering", "count_sibling_numberings", + "OutlineState", + "find_keyword_clique", "detect_body_headings", "CliqueFilterContext", + "partition_candidates", "interleave_clusters", "push_heading_to_state", "should_reject_heading", "find_parent_heading", "HierarchyStack", "extract_sub_headings", "min_font_distance", + "extract_top_level_headings", "is_outline_valid", "is_chapter_outline_valid", "mark_outline_block_types", "compute_max_heading_gap", "has_table_or_prominent", + "build_heading_from_block", + "assemble_outline", + "outline_to_dict_tree", +] diff --git a/pageindex/flash/outline_assembly/assembly.py b/pageindex/flash/outline_assembly/assembly.py new file mode 100644 index 000000000..2f2da6ca1 --- /dev/null +++ b/pageindex/flash/outline_assembly/assembly.py @@ -0,0 +1,341 @@ +"""Final outline assembly and conversion to the output dict tree.""" + +from __future__ import annotations + +from typing import Any, Callable, Optional +from ..model import ( + style_key, left_aligned, right_aligned, center_aligned, x_aligned, rect_union, + Rect, last_span, avg_char_width, raw_text_of_line, heading_score, numbering_text, numbering_value, numbering_kind, + reading_order_key, left_edge_key, _trim_unicode_ws, _round_half_up_to_int, Line, last_line_of, first_span_of, block_text, deaccented_text, letter_count, dominant_style_of, info_weight, dominant_font_size, is_upper_dominant, is_caps_heavy, alignment_code, Block, +) +from ..tokens import ( + Token, TokenView, wrap_tokens, enumerate_tokens, last_token, trie_prefix_match, first_token, set_case_fold, TrieConfig, build_trie, tokenize_block, avg_char_width as avg_char_width_fn, trie_full_match, first_anchor_span, is_char_token, is_word_token, +) + +from .candidates import ( + HeadingCandidate, + OutlineNode, + heading_order_key, +) +from .style_context import ( + OutlineState, + compare_heading_depth, +) +from .cliques import ( + find_keyword_clique, + CliqueFilterContext, + detect_body_headings, + partition_candidates, + interleave_clusters, +) +from .selection import ( + should_reject_heading, + push_heading_to_state, + HierarchyStack, + find_parent_heading, + extract_sub_headings, +) + + +def mark_outline_block_types(item_list: list[OutlineNode]) -> None: + """Mark outline blocks as numbered or unnumbered headings.""" + for block in item_list: + block.heading.group_slot.type = 8 if block.heading.has_numbering else 7 + mark_outline_block_types(block.child_nodes) + + +def compute_max_heading_gap(outline_nodes: list[OutlineNode], other_number: int) -> dict: + """Compute the maximum page-position gap between outline nodes.""" + if not outline_nodes: + return {"max_gap": 0, "last_page_position": other_number} + heading = 0 + for stack_outline_node in outline_nodes: + page_pos = stack_outline_node.heading.page.page_index + stack_outline_node.heading.auxiliary_slot + heading = max(heading, page_pos - other_number) + other_number = page_pos + rec = compute_max_heading_gap(stack_outline_node.child_nodes, other_number) + heading = max(heading, rec["max_gap"]) + other_number = rec["last_page_position"] + return {"max_gap": heading, "last_page_position": other_number} + + +def has_table_or_prominent(outline_nodes: list[OutlineNode]) -> bool: + """Return True if any heading is a table-like or prominent entry.""" + return any(secondary_item.heading.type == 5 or secondary_item.heading.is_prominent for secondary_item in outline_nodes) + + +def is_landscape_or_empty(doc) -> bool: + """Return True for mostly-landscape or near-empty documents with little outline text.""" + if doc.secondary_slot.secondary_slot >= 1e3: + return False + secondary_item = 0 + candidate_item = 0.0 + for page in doc.primary_slot: + if page.bounds.bbox_width() > page.bounds.bbox_height() and page.primary_slot.secondary_slot < 1e3: + secondary_item += 1 + candidate_item += page.primary_slot.secondary_slot + count_item = len(doc.primary_slot) + return secondary_item >= 0.9 * count_item or (secondary_item >= 0.7 * count_item and candidate_item >= 0.5 * doc.secondary_slot.state_slot) + + +# --------------------------------------------------------------------------- # +# Build a heading candidate from a block # +# --------------------------------------------------------------------------- # + + +def build_heading_from_block(block: Block, page, anchor: Optional[Block] = None) -> HeadingCandidate: + """Build a heading candidate wrapper for a heading block.""" + tokens = tokenize_block(block) + # Extract structural numbering from the leading line. + item_list: list[int] = [] + has_numbering = False + prefix: Optional[TokenView] = None + title: TokenView = tokens + if numbering_kind(block.line()) == 1: + num_str = numbering_text(block.line()) + if num_str: + try: + parts = [int(number_part) for number_part in num_str.replace(".", ".").split(".") if number_part.strip()] + if all(0 <= number_part < 1000 for number_part in parts): + item_list = parts + has_numbering = True + # Strip the leading number tokens from g + skip = 0 + while skip < tokens.length: + tok = tokens.token_at(skip) + if tok is None: + break + if tok.type == 1 or tok.str in "..": + skip += 1 + else: + break + title = tokens.slice(skip) + except (ValueError, AttributeError): + pass + + # Type from labeled-section classification or from numbering. + marker_type = getattr(block, "marker_slot", 0) or 0 + if marker_type == 4: + type_ = 4 + elif marker_type == 5: + type_ = 5 + elif marker_type == 11: + type_ = 11 + elif item_list: + type_ = 1 + elif is_caps_heavy(block) and block.line_count() == 1: + type_ = 2 # uppercase short heading + else: + type_ = 0 + + # Prominence flag: big font / bold-and-prominent. + body_size_threshold = page.primary_slot.primary_slot + 0.5 if page.primary_slot else 0 + ja_flag = ( + block.avg_font_size() > body_size_threshold + 1.5 + or (block.bold_frac() > 0.5 and block.avg_font_size() >= body_size_threshold) + ) + + return HeadingCandidate( + type_=type_, + page=page, + group_value=block, + anchor=anchor, + numbering_value=item_list, + tokens=prefix, + title_tokens=title, + has_numbering_flag=has_numbering, + prominent_flag=ja_flag, + ) + + +# --------------------------------------------------------------------------- # +# Main outline assembler # +# --------------------------------------------------------------------------- # + + +def assemble_outline(doc, labeled: list[OutlineNode]) -> list[OutlineNode]: + """Produce the outline tree as a list of outline nodes. Arguments: ``doc`` is the document state; ``labeled`` is the list of outline nodes wrapping labeled headings. Output is a list of root outline nodes. Each node contains child nodes recursively. """ + # ----- Stage 1: collect general headings. + from ..heading_detection import build_doc_heading_candidates + # Labeled headings prime the type gates used by general heading filtering. + general: list[HeadingCandidate] = build_doc_heading_candidates(doc, labeled) + + # ----- Stage 2: merge with labeled + if len(labeled) + len(general) > 0: + combined = list(general) + for labeled_region_node in labeled: + combined.append(labeled_region_node.heading) + combined.sort(key=heading_order_key) + # Build the keyword clique before body-heading filtering so the filter + # can test whether a block is already represented in the candidate tree. + clique = find_keyword_clique(combined) + filtered = detect_body_headings(CliqueFilterContext(doc, combined, lambda line, other_line: compare_heading_depth(line, other_line, clique))) + general.extend(filtered) + # No dedup here: duplicate candidates that wrap the same block are + # collapsed downstream by partitioning and already-placed-block checks. + general = sorted(general, key=heading_order_key) + + # ----- Stage 3: partition + cluster + if labeled: + # No pre-filter: partitioning re-separates labeled vs general, so any + # labeled block backfilled into the general list is handled there. + result = partition_candidates(general, labeled) + general = result["remaining"] + labeled = result["labeled"] + clusters = interleave_clusters(general, labeled) + else: + clusters = [{"labeled_anchor": None, "cluster_candidates": general}] + + # ----- Stage 4: assemble tree + state = OutlineState(clusters) + if not state.measure_slot and state.option_slot <= state.previous_slot: + return [] + + out: list[OutlineNode] = [] + for cluster in clusters: + cluster_anchor = cluster.get("labeled_anchor") + cluster_candidates = cluster.get("cluster_candidates", []) + if cluster_anchor is not None: + push_heading_to_state(state, cluster_anchor.heading) + out.append(cluster_anchor) + sub = extract_sub_headings(doc, state, cluster_anchor, cluster_candidates) + target = cluster_anchor.child_nodes if cluster_anchor is not None else out + target.extend(sub) + + sub_clique = find_keyword_clique(cluster_candidates) if cluster_candidates else None + stack = HierarchyStack(sub_clique) + for insertion_candidate in cluster_candidates: + if should_reject_heading(state, insertion_candidate): + continue + push_heading_to_state(state, insertion_candidate) + insertion_candidate.group_slot.used_as_heading = True + stack_outline_node = OutlineNode(insertion_candidate) + parent = find_parent_heading(stack, insertion_candidate) + if parent is not None: + parent.child_nodes.append(stack_outline_node) + elif cluster_anchor is not None: + cluster_anchor.child_nodes.append(stack_outline_node) + else: + out.append(stack_outline_node) + stack.push(stack_outline_node) + return out + + +# --------------------------------------------------------------------------- # +# Outline tree -> PageIndex dict tree # +# --------------------------------------------------------------------------- # + + +def _flatten_outline_nodes(outline_node_list: list[OutlineNode]) -> list[OutlineNode]: + """Walk an outline tree DFS to a flat list, preserving order.""" + out: list[OutlineNode] = [] + + def _walk_nodes(items: list[OutlineNode]) -> None: + for item in items: + out.append(item) + if item.child_nodes: + _walk_nodes(item.child_nodes) + + _walk_nodes(outline_node_list) + return out + + +def _heading_appears_at_page_top(heading: HeadingCandidate) -> bool: + """Return whether a heading begins its page with no flowing content above it.""" + top_heading = heading.group_slot + page = heading.page + if top_heading is None or page is None: + return True + group_index = getattr(top_heading, "reading_order_index", 0) + for block in (page.secondary_slot or []): + if block is top_heading or getattr(block, "reading_order_index", 0) >= group_index: + continue # only blocks before the heading + if block.char_count() <= 0: + continue # no text + if block.type in (1, 2, 12): # header / footer / watermark + continue + return False # real content precedes the heading + return True + + +def outline_to_dict_tree(outline_node_list: list[OutlineNode], total_pages: int) -> list[dict]: + """Convert the outline tree directly to PageIndex JSON shape. Preserves the natural outline nesting without font-overlay rewriting. """ + flat_nodes: list[dict] = [] + + def _walk_nodes(items: list[OutlineNode]) -> list[dict]: + result: list[dict] = [] + for item in items: + # Title text is prefix tokens plus heading tokens. + prefix_tokens = item.heading.secondary_slot + token = item.heading.primary_slot + child = str(prefix_tokens) if prefix_tokens is not None else "" + node = str(token) if token is not None else "" + title = (child + " " + node) if child else node + # Trim with the Unicode WhiteSpace+LineTerminator set, not Python's + # str.strip set: they differ on U+FEFF, U+0085, and U+001C-1F. + title = _trim_unicode_ws(title) + if not title: + if item.child_nodes: + result.extend(_walk_nodes(item.child_nodes)) + continue + node = { + "title": title, + "node_id": "", + "start_index": item.heading.page.page_index, + "end_index": item.heading.page.page_index, + "nodes": _walk_nodes(item.child_nodes) if item.child_nodes else [], + "_appear_start": _heading_appears_at_page_top(item.heading), + } + flat_nodes.append(node) + result.append(node) + return result + + root = _walk_nodes(outline_node_list) + + # Fill end_index via DFS-order next-start - 1; last node extends to doc end. + flat: list[dict] = [] + + def _collect(nodes: list[dict]) -> None: + for count_item in nodes: + flat.append(count_item) + _collect(count_item["nodes"]) + + _collect(root) + for line, outline_entry in enumerate(flat): + if line + 1 < len(flat): + nxt = flat[line + 1] + # page_index post_processing (utils.post_processing): if the next + # heading starts at the top of its page, this section ends the page + # before it; otherwise the next heading sits below this section's + # tail, so the two share that boundary page and the end extends onto + # it. + boundary = ( + nxt["start_index"] - 1 + if nxt["_appear_start"] + else nxt["start_index"] + ) + else: + boundary = total_pages + outline_entry["end_index"] = max( + outline_entry["start_index"], + boundary if boundary > outline_entry["start_index"] else outline_entry["start_index"], + ) + if flat: + flat[-1]["end_index"] = max(flat[-1]["start_index"], total_pages) + + # Stable DFS pre-order node ids, zero-padded to 4 (PageIndex convention; + # uses zero-padded depth-first ids). Drop the + # transient appear_start marker now that end_index is settled. + for line, outline_entry in enumerate(flat): + outline_entry["node_id"] = str(line).zfill(4) + del outline_entry["_appear_start"] + + def _drop_empty_children(nodes: list[dict]) -> list[dict]: + for count_item in nodes: + if count_item["nodes"]: + _drop_empty_children(count_item["nodes"]) + else: + del count_item["nodes"] + return nodes + + return _drop_empty_children(root) diff --git a/pageindex/flash/outline_assembly/candidates.py b/pageindex/flash/outline_assembly/candidates.py new file mode 100644 index 000000000..630f4ecc2 --- /dev/null +++ b/pageindex/flash/outline_assembly/candidates.py @@ -0,0 +1,255 @@ +"""Heading candidate and outline node types plus ordering and signature helpers.""" + +from __future__ import annotations + +from ..model import ( + style_key, left_aligned, right_aligned, center_aligned, x_aligned, rect_union, + Rect, last_span, avg_char_width, raw_text_of_line, heading_score, numbering_text, numbering_value, numbering_kind, + reading_order_key, left_edge_key, _trim_unicode_ws, _round_half_up_to_int, Line, last_line_of, first_span_of, block_text, deaccented_text, letter_count, dominant_style_of, info_weight, dominant_font_size, is_upper_dominant, is_caps_heavy, alignment_code, Block, +) +from ..stats import style_key as style_key_fn, column_index_of, tally_scripts, dominant_script_family, ScriptHistogram +from ..tokens import ( + Token, TokenView, wrap_tokens, enumerate_tokens, last_token, trie_prefix_match, first_token, set_case_fold, TrieConfig, build_trie, tokenize_block, avg_char_width as avg_char_width_fn, trie_full_match, first_anchor_span, is_char_token, is_word_token, +) + + +# --------------------------------------------------------------------------- # +# Heading candidate wrapper # +# --------------------------------------------------------------------------- # + + +def _viewport_y_fraction(viewport_box, rot: int, user_x: float, user_y: float) -> float: + """Return viewport-normalized y coordinate for a PDF user-space point. Applies the same page ``/Rotate`` and the unrotated view box to a user-space point, then normalises the y component by the viewport height.""" + x_min, y_min, x_max, y_max = viewport_box + center_x = (x_max + x_min) / 2.0 + center_y = (y_max + y_min) / 2.0 + rotation = rot % 360 + if rotation < 0: + rotation += 360 + if rotation == 90: + x_axis_scale, y_axis_scale = 1, 0 + x_axis_sign = 0 + elif rotation == 180: + x_axis_scale, y_axis_scale = 0, 1 + x_axis_sign = -1 + elif rotation == 270: + x_axis_scale, y_axis_scale = -1, 0 + x_axis_sign = 0 + else: + x_axis_scale, y_axis_scale = 0, -1 + x_axis_sign = 1 + if x_axis_sign == 0: + viewport_offset = abs(center_x - x_min) + height = abs(x_max - x_min) + else: + viewport_offset = abs(center_y - y_min) + height = abs(y_max - y_min) + # transform[1]=b, transform[3]=d, transform[5]=off_y - b*cx - d*cy; + # the viewport y-coordinate = b*x + d*y + transform[5]. + viewport_y = x_axis_scale * user_x + y_axis_scale * user_y + (viewport_offset - x_axis_scale * center_x - y_axis_scale * center_y) + return viewport_y / (height or 1.0) + + +class HeadingCandidate: + """One heading candidate. It stores the candidate type, page, underlying block, optional anchor block, numbering array, optional prefix tokens, title tokens, structural-numbering flag, prominence flag, dominant script family, and vertical page position.""" + + __slots__ = ("type", "page", "group_slot", "tertiary_slot", "numbering", "secondary_slot", "primary_slot", "has_numbering", "is_prominent", "state_slot", "auxiliary_slot") + + def __init__(self, type_, page, group_value, anchor, numbering_value, tokens, title_tokens, has_numbering_flag, prominent_flag): + self.type = type_ + self.page = page + self.group_slot = group_value + self.tertiary_slot = anchor + self.numbering = numbering_value or [] + self.secondary_slot = tokens + self.primary_slot = title_tokens + self.has_numbering = has_numbering_flag + self.is_prominent = prominent_flag + # Compute the dominant script family over prefix and title tokens. + acc = ScriptHistogram() + if tokens is not None: + for token_value in tokens: + tally_scripts(acc, token_value.str) + if title_tokens is not None: + for token_value in title_tokens: + tally_scripts(acc, token_value.str) + self.state_slot = dominant_script_family(acc) + # Compute vertical fraction on page. The viewport applies the page + # /Rotate and view box; when that metadata is absent, fall back to the + # origin-0 upright shortcut. + viewport_box_value = getattr(page, "viewport_box", None) + if viewport_box_value is not None: + self.auxiliary_slot = _viewport_y_fraction(viewport_box_value, getattr(page, "rot", 0) or 0, group_value.left_edge(), group_value.top_edge()) + else: + page_height = page.bounds.bbox_height() or 1.0 + self.auxiliary_slot = (page.bounds.top_edge() - group_value.top_edge()) / page_height + + def __repr__(self) -> str: # diagnostic + return f"" + + +# --------------------------------------------------------------------------- # +# Outline node # +# --------------------------------------------------------------------------- # + + +class OutlineNode: + """Heading plus child outline nodes.""" + + __slots__ = ("heading", "child_nodes") + + def __init__(self, heading: HeadingCandidate): + self.heading = heading + self.child_nodes: list["OutlineNode"] = [] + + +# --------------------------------------------------------------------------- # +# Page and reading-position comparator. +# --------------------------------------------------------------------------- # + + +def compare_heading_order(heading_candidate: HeadingCandidate, other_heading_candidate: HeadingCandidate) -> float: + """Order by page, then block reading position.""" + if heading_candidate.page.page_index != other_heading_candidate.page.page_index: + return heading_candidate.page.page_index - other_heading_candidate.page.page_index + return _compare_block_order(heading_candidate.group_slot, other_heading_candidate.group_slot) + + +def _compare_block_order(block: Block, other_block: Block) -> float: + """Compare by column index first, then by reading position.""" + from ..model import cmp_reading_order + from ..stats import column_index_of as _column_index + heading_anchor = _column_index(block) + other_column_index = _column_index(other_block) + if heading_anchor != other_column_index: + return heading_anchor - other_column_index + return cmp_reading_order(block, other_block) + + +def heading_order_key(heading_candidate: HeadingCandidate) -> tuple: + from ..stats import column_index_of as _column_index + return (heading_candidate.page.page_index, _column_index(heading_candidate.group_slot), -heading_candidate.group_slot.top_edge(), -heading_candidate.group_slot.bottom_edge(), heading_candidate.group_slot.left_edge(), heading_candidate.group_slot.right_edge()) + + +# --------------------------------------------------------------------------- # +# Candidate compatibility and style-cluster helpers # +# --------------------------------------------------------------------------- # + + +def is_script_compatible(number: int, other_heading_candidate: HeadingCandidate) -> bool: + """Return whether candidate script/type is compatible with prior context. Args: a: integer previous script/type context b: heading candidate """ + candidate_item = other_heading_candidate.state_slot + if candidate_item == 0 or candidate_item == 2 or candidate_item == 10: + return True + if number == candidate_item: + return False + if other_heading_candidate.type == 5: + return False + if other_heading_candidate.is_prominent: + return False + if len(other_heading_candidate.numbering) > 0: + return False + if number == 3 and candidate_item == 9: + return False + if number == 9 and candidate_item == 3: + return False + if number == 7 and candidate_item == 5: + return False + if number == 6 and candidate_item == 3: + return False + return True + + +def heading_signature(heading_candidate: HeadingCandidate) -> str: + """Return a full heading signature including numbering or text.""" + if len(heading_candidate.numbering) > 0: + # Numbering arrays are serialized as comma-joined values, not Python + # list representations. + return f"{heading_candidate.type}|{','.join(map(str, heading_candidate.numbering))}" + heading = f"{heading_candidate.type}|" + if heading_candidate.primary_slot is not None: + for token in heading_candidate.primary_slot: + if is_char_token(token): + heading += token.str.lower() + return heading + + +def parent_signature(heading_candidate: HeadingCandidate) -> str: + """Return the signature of the candidate's parent numbering prefix.""" + secondary_item = f"{heading_candidate.type}|" + for candidate_item in range(len(heading_candidate.numbering) - 1): + if candidate_item > 0: + secondary_item += "," + secondary_item += str(heading_candidate.numbering[candidate_item]) + return secondary_item + + +def cached_signature(primary_item: "StyleCluster", other_heading_candidate: HeadingCandidate) -> str: + """Cached heading-signature lookup. Keyed by the candidate object itself, not by object id, because addresses can be reused after a discarded object is collected.""" + candidate_item = primary_item.auxiliary_slot.get(other_heading_candidate) + if candidate_item is not None: + return candidate_item + candidate_item = heading_signature(other_heading_candidate) + primary_item.auxiliary_slot[other_heading_candidate] = candidate_item + return candidate_item + + +def is_in_oo_range(primary_item: "StyleCluster", other_heading_candidate: HeadingCandidate) -> bool: + """Return True if the candidate lies within a style cluster's order range.""" + if primary_item.primary_slot is None or primary_item.tertiary_slot is None: + return False + return compare_heading_order(other_heading_candidate, primary_item.primary_slot) >= 0 and compare_heading_order(other_heading_candidate, primary_item.tertiary_slot) <= 0 + + +def has_style_neighbor(style: "StyleCluster", other_heading_candidate: HeadingCandidate, candidate_item: float) -> bool: + """Return True if a candidate is close to a compatible style neighbor.""" + candidate_score = heading_score(other_heading_candidate.group_slot) + + def cmp_target(): + return {"z": candidate_score, "HeadingCandidate": other_heading_candidate} + + matched = [False] + + def fcheck(item): + if abs(candidate_score - heading_score(item.group_slot)) >= candidate_item: + return True + # Within tolerance, check signature match: + measure_item = other_heading_candidate.group_slot + line_value = item.group_slot + if abs(heading_score(measure_item) - heading_score(line_value)) >= candidate_item: + state_item = False + elif len(other_heading_candidate.numbering) > 0 and len(item.numbering) > 0: + state_item = (other_heading_candidate.type == item.type and len(other_heading_candidate.numbering) == len(item.numbering)) + elif (len(other_heading_candidate.numbering) <= 0 and len(item.numbering) > 1) or (len(item.numbering) <= 0 and len(other_heading_candidate.numbering) > 1): + state_item = False + else: + block = measure_item.isolated_centered + other_centered = line_value.isolated_centered + if block or other_centered: + state_item = (block == other_centered) + elif dominant_style_of(measure_item) == dominant_style_of(line_value): + state_item = True + else: + if first_span_of(measure_item).font_style() != first_span_of(line_value).font_style(): + state_item = False + else: + state_item = abs(dominant_font_size(measure_item) - dominant_font_size(line_value)) < candidate_item + if state_item: + matched[0] = True + return True + return False + + # Walk sibling candidates in both directions from the candidate's page position + if style.secondary_slot is None: + return False + # SortedKeyList walk + target_key = (candidate_score, heading_order_key(other_heading_candidate)) + idx = style.secondary_slot.bisect_right(other_heading_candidate) + for scan_index in range(idx, len(style.secondary_slot)): + if fcheck(style.secondary_slot[scan_index]): + break + if not matched[0]: + for scan_index in range(idx - 1, -1, -1): + if fcheck(style.secondary_slot[scan_index]): + break + return matched[0] diff --git a/pageindex/flash/outline_assembly/cliques.py b/pageindex/flash/outline_assembly/cliques.py new file mode 100644 index 000000000..f87fd4e81 --- /dev/null +++ b/pageindex/flash/outline_assembly/cliques.py @@ -0,0 +1,403 @@ +"""Keyword cliques, clique trees, body-heading detection, and candidate partitioning.""" + +from __future__ import annotations + +from typing import Any, Callable, Optional +from ..model import ( + style_key, left_aligned, right_aligned, center_aligned, x_aligned, rect_union, + Rect, last_span, avg_char_width, raw_text_of_line, heading_score, numbering_text, numbering_value, numbering_kind, + reading_order_key, left_edge_key, _trim_unicode_ws, _round_half_up_to_int, Line, last_line_of, first_span_of, block_text, deaccented_text, letter_count, dominant_style_of, info_weight, dominant_font_size, is_upper_dominant, is_caps_heavy, alignment_code, Block, +) +from ..stats import style_key as style_key_fn, column_index_of, tally_scripts, dominant_script_family, ScriptHistogram +from ..tokens import ( + Token, TokenView, wrap_tokens, enumerate_tokens, last_token, trie_prefix_match, first_token, set_case_fold, TrieConfig, build_trie, tokenize_block, avg_char_width as avg_char_width_fn, trie_full_match, first_anchor_span, is_char_token, is_word_token, +) + + +# --------------------------------------------------------------------------- # +# Numbering-pattern clique selection. +# --------------------------------------------------------------------------- # + + +# Section-keyword trie shared with outline filtering. +from ..outline import SECTION_KEYWORD_TRIE + +from .candidates import ( + HeadingCandidate, + OutlineNode, + compare_heading_order, + heading_order_key, + has_style_neighbor, +) +from .style_context import ( + StyleCluster, + is_compatible_with_context, + OutlineContext, +) + + +def find_keyword_clique(heading_candidates: list[HeadingCandidate]) -> Optional[StyleCluster]: + """Find the largest clique of section-keyword headings sharing a font signature.""" + buckets: dict[str, StyleCluster] = {} + for candidate_item in heading_candidates: + if candidate_item.primary_slot is None: + continue + if not trie_full_match(SECTION_KEYWORD_TRIE, candidate_item.primary_slot): + continue + first = first_token(candidate_item.primary_slot) + if first is None or not first.anchor_ranges: + continue + font_size = first_anchor_span(first).font_style() + style_cluster = buckets.get(font_size) + if style_cluster is not None: + if style_cluster.has_nearby_duplicate(candidate_item): + return None # conflict -> abort + if has_style_neighbor(style_cluster, candidate_item, 2.0): + style_cluster.add(candidate_item) + else: + style_cluster = StyleCluster() + buckets[font_size] = style_cluster + style_cluster.add(candidate_item) + winner: Optional[StyleCluster] = None + max_size = 0 + for style_cluster in buckets.values(): + if style_cluster.size() > max_size: + winner = style_cluster + max_size = style_cluster.size() + if winner is None or max_size <= 1: + return None + for entry_item in heading_candidates: + if winner.contains(entry_item): + continue + if has_style_neighbor(winner, entry_item, 0.5): + winner.add(entry_item) + return winner + + +# --------------------------------------------------------------------------- # +# Clique-based clusters # +# --------------------------------------------------------------------------- # + + +class CliqueTreeNode: + """Tree node used by clique-based heading filtering. Each node holds a heading candidate, parent pointer, child list, and sibling links. ``next`` walks the in-order successor.""" + + __slots__ = ("heading", "parent", "primary_slot", "secondary_slot", "tertiary_slot") + + def __init__(self, heading, parent): + self.heading = heading + self.parent = parent if parent is not None else self + self.primary_slot: list = [] + self.secondary_slot = None + self.tertiary_slot = None + + def next(self): + if self.primary_slot: + return self.primary_slot[0] + if self.secondary_slot is not None: + return self.secondary_slot + return find_ancestor_next_sibling(self.parent) + + +def find_ancestor_next_sibling(primary_item: CliqueTreeNode): + """walk up parents until we find one with a next sibling.""" + if primary_item.parent is primary_item: + return None + return primary_item.secondary_slot or find_ancestor_next_sibling(primary_item.parent) + + +def descend_to_deepest_last(primary_item: CliqueTreeNode) -> CliqueTreeNode: + """descend to deepest last-child.""" + while primary_item.primary_slot: + primary_item = primary_item.primary_slot[-1] + return primary_item + + +def append_tree_child(ao_tree, parent_node: CliqueTreeNode, heading) -> None: + """Append a new clique-tree child and advance the builder cursor.""" + new_node = CliqueTreeNode(heading, parent_node) + last = parent_node.primary_slot[-1] if parent_node.primary_slot else None + if last is not None: + last.secondary_slot = new_node + new_node.tertiary_slot = last + parent_node.primary_slot.append(new_node) + ao_tree.primary_slot = new_node + + +class CliqueTreeBuilder: + """(class at table entry). Builds a clique-tree from a heading list using a comparator. Each heading is placed by walking the cursor up/down based on comparator result. Depth capped at 8. """ + + __slots__ = ("root", "primary_slot") + + def __init__(self, headings: list[HeadingCandidate], compare): + self.root = CliqueTreeNode(None, None) + self.primary_slot = self.root + depth = 0 + for height in headings: + while True: + if self.primary_slot is self.root: + append_tree_child(self, self.primary_slot, height) + depth += 1 + break + comparison = compare(self.primary_slot.heading, height) + if comparison < 0: + self.primary_slot = self.primary_slot.parent + depth -= 1 + else: + if comparison > 0 and depth < 8: + append_tree_child(self, self.primary_slot, height) + depth += 1 + else: + append_tree_child(self, self.primary_slot.parent, height) + break + + +def block_style_signature(block) -> str: + """Return a block-style signature combining dominant style and caps-heavy state.""" + from ..model import dominant_style_of, is_caps_heavy + # The boolean portion is lower-case because the signature is used as an + # opaque stable key. + return f"{dominant_style_of(block)} {'true' if is_caps_heavy(block) else 'false'}" + + +def is_member_of_tree(doc, block, target_sig: str, sentence_like: bool, node: CliqueTreeNode) -> bool: + """Return whether the target block is already represented by an ancestor in the candidate tree, using heading signature, body-text weight, and recursive parent traversal.""" + from ..model import is_sentence_like + from ..stats import info_weight + if node is None or node.parent is node: + return False + tree_parent_candidate = node.heading + if tree_parent_candidate is None or tree_parent_candidate.type == 5 or tree_parent_candidate.is_prominent: + return False + if len(tree_parent_candidate.numbering) > 0: + return is_member_of_tree(doc, block, target_sig, sentence_like, node.parent) + parent_block = tree_parent_candidate.group_slot + if target_sig != block_style_signature(parent_block) or (sentence_like and is_sentence_like(parent_block)): + return is_member_of_tree(doc, block, target_sig, sentence_like, node.parent) + if info_weight(block.char_stats) >= max(100, 4 * info_weight(parent_block.char_stats)): + return is_member_of_tree(doc, block, target_sig, sentence_like, node.parent) + return True + + +def can_share_heading_style(heading, other_heading, neighbor_map) -> bool: + """Return whether two blocks can share a heading-style assignment after checking overlap, style signature, neighboring ambiguity, and predecessor consistency.""" + from ..model import y_overlaps, dominant_style_of + from ..heading_detection import neighbor_right, neighbor_above + if other_heading is None or not y_overlaps(heading, other_heading) or dominant_style_of(heading) != dominant_style_of(other_heading): + return False + heading_above = neighbor_above(neighbor_map, heading) + other_above = neighbor_above(neighbor_map, other_heading) + heading_right = neighbor_right(neighbor_map, heading) + other_right = neighbor_right(neighbor_map, other_heading) + if (heading_above is not None and heading_above.marker_slot != 0 + or other_above is not None and other_above.marker_slot != 0 + or heading_right is not None and heading_right.marker_slot != 0 + or other_right is not None and other_right.marker_slot != 0): + return True + if (heading_right is not other_right + and (heading_right is not None and heading_right.is_body_paragraph) + and (other_right is not None and other_right.is_body_paragraph)): + return False + return True + + +def compare_block_order(left_value, right_value) -> float: + """Compare blocks or lines by column index first, then reading position.""" + from ..model import cmp_reading_order + from ..stats import column_index_of + left_column_index = column_index_of(left_value) + right_column_index = column_index_of(right_value) + if left_column_index != right_column_index: + return left_column_index - right_column_index + return cmp_reading_order(left_value, right_value) + + +def heading_precedes_line(line_heading_candidate: HeadingCandidate, page, line) -> bool: + """Return whether the heading candidate sorts before the given page/line position.""" + if line_heading_candidate.page.page_index < page.page_index: + return True + if line_heading_candidate.page.page_index != page.page_index: + return False + return compare_block_order(line_heading_candidate.group_slot, line) < 0 + + +class CliqueFilterContext: + """State for clique-based body-heading discovery.""" + + __slots__ = ("auxiliary_slot", "state_slot", "tertiary_slot", "measure_slot", "secondary_slot", "option_slot", "primary_slot", "candidates", "compare") + + def __init__(self, doc, candidates: list[HeadingCandidate], compare): + self.auxiliary_slot = doc + self.state_slot: set = set() + self.tertiary_slot: dict = {} + for reference_item in candidates: + self.state_slot.add(reference_item.group_slot) + if reference_item.has_numbering: + continue + if len(reference_item.numbering) > 0: + continue + sig = block_style_signature(reference_item.group_slot) + self.tertiary_slot[sig] = self.tertiary_slot.get(sig, 0) + 1 + self.measure_slot = CliqueTreeBuilder(candidates, compare) + self.secondary_slot = self.measure_slot.root + self.option_slot = CliqueTreeBuilder(list(reversed(candidates)), compare) + self.primary_slot = self.option_slot.primary_slot + self.candidates = candidates + self.compare = compare + + +def detect_body_headings(filter_context: CliqueFilterContext) -> list[HeadingCandidate]: + """Discover body headings by comparing unvisited blocks against clique trees.""" + from ..model import style_key, dominant_style_of, last_span, last_line_of, first_span_of + from ..heading_detection import neighbor_right, neighbor_above, closest_body_neighbor_above, PageNeighborMap as _bo_class, is_cover_page + from ..tokens import first_token, tokenize_block + + out: list[HeadingCandidate] = [] + if not filter_context.candidates: + return out + + # Reset cursors to root of forward tree / deepest of reversed tree. + filter_context.secondary_slot = filter_context.measure_slot.root + filter_context.primary_slot = filter_context.option_slot.primary_slot + + for page in filter_context.auxiliary_slot.primary_slot: + if is_cover_page(filter_context.auxiliary_slot, page): + continue + all_blocks = page.output_slot + if len(all_blocks) <= 0: + continue + neighbor_cache = _bo_class(page) + for block in page.secondary_slot: + # Advance the forward tree cursor while the next node is before + # the current page and block in reading order. + while True: + next_item = filter_context.secondary_slot.next() + if (next_item is None + or next_item.heading is None + or not heading_precedes_line(next_item.heading, page, block)): + break + filter_context.secondary_slot = next_item + # Advance the reverse tree cursor while the predecessor is before + # cursor's heading is still before the current page and block. + while filter_context.primary_slot.heading is not None and heading_precedes_line(filter_context.primary_slot.heading, page, block): + left_sib = filter_context.primary_slot.tertiary_slot + filter_context.primary_slot = descend_to_deepest_last(left_sib) if left_sib is not None else filter_context.primary_slot.parent + if filter_context.primary_slot is filter_context.option_slot.root: + break + + if block in filter_context.state_slot: + continue + if filter_context.secondary_slot.heading is None: + continue + # Body-heading filters. + if (block.char_count() <= 0 or block.skew_frac() > 1 + or (block.char_count() <= 1 and block.char_stats.secondary_slot != 4) + or block.line_count() >= 5 + or block.type != 0 + or block.marker_slot != 0 + or (block.char_stats.primary_slot[2] <= 0 and block.char_stats.primary_slot[4] <= 0)): + continue + if block.measure_slot: + continue + value = block.bold_frac() + if 0.1 < value < 0.9: + continue + block_style = dominant_style_of(block) + first_tok = first_token(tokenize_block(block)) + # Compare against the dominant style, first span, last token + # anchor, and last span. The last anchor matters for wrapped tokens. + anchor = first_tok.anchor_ranges[-1].anchor_span if (first_tok is not None and first_tok.anchor_ranges) else None + if (block_style != style_key(first_span_of(block)) + and (anchor is None or block_style != style_key(anchor)) + and block_style != style_key(last_span(last_line_of(block)))): + continue + if block_style == page.primary_slot.auxiliary_slot: + continue + above = neighbor_above(neighbor_cache, block) + if (above is not None + and above.bottom_edge() - block.top_edge() < 0.3 * block.avg_font_size() + and block.line_count() > 1): + continue + if above is not None and above.type == 3: + continue + sig = block_style_signature(block) + pred_neigh = neighbor_right(neighbor_cache, block) + # Reject when the block repeats the style signature of a close + # vertical or right-side neighbor. + if above is not None and sig == block_style_signature(above): + continue + if pred_neigh is not None and sig == block_style_signature(pred_neigh): + continue + previous_block = all_blocks[block.orig_index - 1] if 0 <= block.orig_index - 1 < len(all_blocks) else None + next_block = all_blocks[block.orig_index + 1] if 0 <= block.orig_index + 1 < len(all_blocks) else None + if can_share_heading_style(block, previous_block, neighbor_cache): + continue + if can_share_heading_style(block, next_block, neighbor_cache): + continue + if filter_context.tertiary_slot.get(sig, 0) < 3: + continue + # Sentence-like flag: enough long lowercase-leading word tokens make + # a block look like body text rather than a heading. + tok_total = 0 + tok_g3 = 0 + for token in tokenize_block(block): + if token.type != 2 or len(token.str) < 5: + continue + tok_total += 1 + if token.primary_slot == 3: + tok_g3 += 1 + sentence_like = tok_g3 >= max(2, tok_total / 2) + # A block must fit either the forward or reverse clique cursor. + if not (is_member_of_tree(filter_context, block, sig, sentence_like, filter_context.secondary_slot) + or is_member_of_tree(filter_context, block, sig, sentence_like, filter_context.primary_slot)): + continue + body_heading_candidate = HeadingCandidate( + 0, page, block, + closest_body_neighbor_above(neighbor_cache, block), + [], None, tokenize_block(block), + False, False, + ) + out.append(body_heading_candidate) + return out + + +# --------------------------------------------------------------------------- # +# Partition candidates and interleave clusters # +# --------------------------------------------------------------------------- # + + +def partition_candidates(heading_candidates: list[HeadingCandidate], other_outline_nodes: list[OutlineNode]) -> dict: + """Partition candidates into labeled-compatible and remaining groups.""" + labeled_headings = [entry_item.heading for entry_item in other_outline_nodes] + accepted_context = OutlineContext(labeled_headings) + remaining: list[HeadingCandidate] = [] + for entry_item in heading_candidates: + if is_compatible_with_context(accepted_context, entry_item): + other_outline_nodes.append(OutlineNode(entry_item)) + accepted_context.add(entry_item) + else: + remaining.append(entry_item) + other_outline_nodes.sort(key=lambda sort_node: heading_order_key(sort_node.heading)) + return {"remaining": remaining, "labeled": other_outline_nodes} + + +def interleave_clusters(heading_candidates: list[HeadingCandidate], other_outline_nodes: list[OutlineNode]) -> list[dict]: + """Interleave general candidates between successive labeled headings. Returns clusters with the labeled heading and intervening candidates. """ + out: list[dict] = [] + index = 0 + previous: Optional[OutlineNode] = None + acc: list[HeadingCandidate] = [] + for labeled_outline_node in other_outline_nodes: + boundary_candidate = labeled_outline_node.heading + while index < len(heading_candidates) and compare_heading_order(heading_candidates[index], boundary_candidate) < 0: + acc.append(heading_candidates[index]) + index += 1 + if acc or previous is not None: + out.append({"labeled_anchor": previous, "cluster_candidates": acc}) + acc = [] + previous = labeled_outline_node + while index < len(heading_candidates): + acc.append(heading_candidates[index]) + index += 1 + out.append({"labeled_anchor": previous, "cluster_candidates": acc}) + return out diff --git a/pageindex/flash/outline_assembly/selection.py b/pageindex/flash/outline_assembly/selection.py new file mode 100644 index 000000000..234856bba --- /dev/null +++ b/pageindex/flash/outline_assembly/selection.py @@ -0,0 +1,385 @@ +"""Heading rejection rules, hierarchy stack, and sub/top-level heading extraction.""" + +from __future__ import annotations + +import math +from typing import Any, Callable, Optional +from ..model import ( + style_key, left_aligned, right_aligned, center_aligned, x_aligned, rect_union, + Rect, last_span, avg_char_width, raw_text_of_line, heading_score, numbering_text, numbering_value, numbering_kind, + reading_order_key, left_edge_key, _trim_unicode_ws, _round_half_up_to_int, Line, last_line_of, first_span_of, block_text, deaccented_text, letter_count, dominant_style_of, info_weight, dominant_font_size, is_upper_dominant, is_caps_heavy, alignment_code, Block, +) +from ..tokens import ( + Token, TokenView, wrap_tokens, enumerate_tokens, last_token, trie_prefix_match, first_token, set_case_fold, TrieConfig, build_trie, tokenize_block, avg_char_width as avg_char_width_fn, trie_full_match, first_anchor_span, is_char_token, is_word_token, +) + +from .candidates import ( + HeadingCandidate, + OutlineNode, + heading_signature, + parent_signature, + is_in_oo_range, + has_style_neighbor, +) +from .style_context import ( + StyleCluster, + count_sibling_numberings, + OutlineState, + compare_heading_depth, +) + + +# --------------------------------------------------------------------------- # +# cp / bp -- state mutators (,) # +# --------------------------------------------------------------------------- # + + +def min_font_distance(state: OutlineState, other_heading_candidate: HeadingCandidate) -> float: + """minimum font-distance between b and any other heading in the same fontStyle bucket within b's line.""" + min_value = math.inf + line = other_heading_candidate.group_slot.line() + for token_list in (other_heading_candidate.secondary_slot, other_heading_candidate.primary_slot): + if token_list is None: + continue + for token in token_list: + if token.type != 2: + continue + for anchor in token.anchor_ranges: + if anchor.line is not line: + return min_value + span = anchor.anchor_span + tree = state.state_slot.get(span.font_style()) + if tree is None: + continue + for entry in tree: + if entry["heading"] is other_heading_candidate: + continue + diff = abs(span.font_size - entry["size"]) + if diff < min_value: + min_value = diff + if diff <= 0: + return 0 + return min_value + + +def should_reject_heading(state: OutlineState, other_heading_candidate: HeadingCandidate) -> bool: + """should we REJECT heading b given current state? True = reject.""" + if other_heading_candidate.type == 0: + for previous in state.style_slot: + if previous is None: + continue + if compare_heading_depth(previous, other_heading_candidate) == 1: + continue + style_cluster = state.marker_slot.get(parent_signature(previous)) + if style_cluster is not None and is_in_oo_range(style_cluster, other_heading_candidate): + return True + if state.primary_slot is not None and state.primary_slot.is_prominent and other_heading_candidate.type == 0: + count = 0 + for candidate_token in tokenize_block(other_heading_candidate.group_slot): + if is_word_token(candidate_token) or candidate_token.type == 1: + count += 1 + if count >= 3: + break + if count >= 3: + return True + if ( + other_heading_candidate.type == 1 and len(other_heading_candidate.numbering) <= 1 + and ( + (0 if (other_heading_candidate.type != 1 or len(other_heading_candidate.numbering) <= 0) else count_sibling_numberings(state.cache_slot, other_heading_candidate, 0)) <= 1 + ) + ): + return True + if other_heading_candidate.type in (1, 5, 9, 10, 7): + reject = False + else: + distance = min_font_distance(state, other_heading_candidate) + if distance <= 0.9: + reject = False + elif distance >= math.inf: + reject = True + else: + reject = not (is_caps_heavy(other_heading_candidate.group_slot) and other_heading_candidate.tertiary_slot is not None and other_heading_candidate.group_slot.bottom_edge() - other_heading_candidate.tertiary_slot.top_edge() < 5 * other_heading_candidate.group_slot.bbox_height()) + if reject: + return True + if other_heading_candidate.type == 1: + first = other_heading_candidate.numbering[0] + if (first < state.secondary_slot and first < state.tertiary_slot) or (state.secondary_slot > 0 and first > state.secondary_slot + 2): + return True + if len(other_heading_candidate.numbering) == 1 and state.auxiliary_slot is not None: + if first == state.tertiary_slot: + return True + existing = state.auxiliary_slot.group_slot + candidate_style = style_key(first_anchor_span(first_token(other_heading_candidate.primary_slot))) if other_heading_candidate.primary_slot is not None and first_token(other_heading_candidate.primary_slot) is not None else "" + state_style = style_key(first_anchor_span(first_token(state.auxiliary_slot.primary_slot))) if state.auxiliary_slot.primary_slot is not None and first_token(state.auxiliary_slot.primary_slot) is not None else "" + if candidate_style != state_style: + # Bold-fraction comparison uses exact half-up integer rounding; + # Python f-string rounding is half-even. + if abs(dominant_font_size(other_heading_candidate.group_slot) - dominant_font_size(existing)) > 0.5 or _round_half_up_to_int(other_heading_candidate.group_slot.bold_frac()) != _round_half_up_to_int(existing.bold_frac()): + return True + if ( + state.primary_slot is not None + and other_heading_candidate.type == 4 and state.primary_slot.type == 4 + and len(state.primary_slot.numbering) > 0 and len(other_heading_candidate.numbering) > 0 + and (state.primary_slot.numbering[0] > other_heading_candidate.numbering[0] or (len(other_heading_candidate.numbering) == 1 and state.primary_slot.numbering[0] == other_heading_candidate.numbering[0])) + ): + return True + if (state.primary_slot is not None and state.primary_slot.type == 8 and len(other_heading_candidate.numbering) <= 0): + from ..model import _strip_diacritics + candidate_tokens = other_heading_candidate.primary_slot or [] + tokens = state.primary_slot.primary_slot or [] + if len(candidate_tokens) == len(tokens): + same = True + for heading in range(len(candidate_tokens)): + token = candidate_tokens[heading] if heading < len(candidate_tokens) else None + state_token = tokens[heading] if heading < len(tokens) else None + if token is None or state_token is None: + same = False + break + if _strip_diacritics(token.str.lower()) != _strip_diacritics(state_token.str.lower()): + same = False + break + if same: + return True + return False + + +def push_heading_to_state(state: OutlineState, other_heading_candidate: HeadingCandidate) -> None: + """Push a heading into the outline state and update level trackers.""" + if len(other_heading_candidate.numbering) > 0: + # Ensure S is long enough + while len(state.style_slot) < len(other_heading_candidate.numbering): + state.style_slot.append(None) + state.style_slot[len(other_heading_candidate.numbering) - 1] = other_heading_candidate + if other_heading_candidate.type == 1: + first = other_heading_candidate.numbering[0] + state.secondary_slot = max(state.secondary_slot, first) + state.tertiary_slot = max(state.tertiary_slot, first) + if len(other_heading_candidate.numbering) == 1: + state.auxiliary_slot = other_heading_candidate + elif other_heading_candidate.type in (8, 9): + state.tertiary_slot = 0 + state.primary_slot = other_heading_candidate + + +# --------------------------------------------------------------------------- # +# Hierarchy-walk stack # +# --------------------------------------------------------------------------- # + + +class HierarchyStack: + """Tree-walk stack of currently open outline nodes.""" + + __slots__ = ("auxiliary_slot", "primary_slot", "secondary_slot", "tertiary_slot") + + def __init__(self, anchor): + self.auxiliary_slot = anchor + self.primary_slot: list[OutlineNode] = [] + self.secondary_slot = False + self.tertiary_slot = False + + def pop(self) -> Optional[OutlineNode]: + return self.primary_slot.pop() if self.primary_slot else None + + def push(self, other_outline_node: OutlineNode) -> None: + self.primary_slot.append(other_outline_node) + self.secondary_slot = self.secondary_slot or other_outline_node.heading.type == 4 + self.tertiary_slot = self.tertiary_slot or other_outline_node.heading.is_prominent + + +def find_parent_heading(stack: HierarchyStack, other_heading_candidate: HeadingCandidate) -> Optional[OutlineNode]: + """Pop entries from the stack until a parent for the candidate is found.""" + heading: Optional[HeadingCandidate] = None + while stack.primary_slot: + stack_outline_node = stack.primary_slot[-1] + state_candidate = stack_outline_node.heading + if other_heading_candidate.is_prominent and len(other_heading_candidate.numbering) <= 1 and state_candidate.type != 8: + stack.pop() + heading = state_candidate + continue + if state_candidate.is_prominent and other_heading_candidate.type == 5: + stack.pop() + heading = state_candidate + continue + cmp = compare_heading_depth(state_candidate, other_heading_candidate, stack.auxiliary_slot) + if cmp != -1: + if cmp == 1: + return stack_outline_node + # Appendix and Roman/letter headings can nest under the current + # parent only when the numbering sequence remains coherent. + if (state_candidate.type != other_heading_candidate.type and other_heading_candidate.type in (4, 2) and not stack.tertiary_slot + and is_appendix_nesting_ok(stack, other_heading_candidate, heading)): + first_number = other_heading_candidate.numbering[0] if other_heading_candidate.numbering else 0 + if heading is None: + if first_number == 1: + return stack_outline_node + else: + # Empty numbering on the previous heading cannot establish + # an increasing appendix sequence. + if other_heading_candidate.type == heading.type and other_heading_candidate.numbering and heading.numbering and first_number > heading.numbering[0]: + return stack_outline_node + stack.pop() + heading = state_candidate + return None + + +def is_appendix_nesting_ok(stack: HierarchyStack, other_heading_candidate: HeadingCandidate, candidate_heading_candidate: Optional[HeadingCandidate]) -> bool: + """Return whether an appendix candidate may be nested under the current stack state. Non-appendix headings always pass; appendix headings pass when the stack is already in appendix mode, has no numbering context, or starts at appendix depth 1..3.""" + if other_heading_candidate.type != 4: + return True + if stack.secondary_slot: + return True + # Last heading info + if not stack.primary_slot: + return True + entry_item = stack.primary_slot[-1].heading + if len(entry_item.numbering) <= 0: + return True + return entry_item.numbering[0] <= 3 + + +# --------------------------------------------------------------------------- # +# Sub-headings within a cluster # +# --------------------------------------------------------------------------- # + + +def extract_sub_headings(doc, state: OutlineState, parent_node: Optional[OutlineNode], cluster_candidates: list[HeadingCandidate]) -> list[OutlineNode]: + """Walk a cluster's candidate list and emit subheadings. The input list is consumed in place so later passes do not reprocess headings already assigned to this cluster.""" + if not cluster_candidates: + return [] + # Content cap: walk from the parent page to the first candidate page and + # abort the cluster if accumulated body-block text exceeds 1000. + from ..stats import info_weight as _info_weight + first = cluster_candidates[0] + page_index = (parent_node.heading.page.page_index - 1) if parent_node is not None else 0 + acc = 0 + end_pg = min(first.page.page_index, len(doc.primary_slot)) + while page_index < end_pg: + heading_page = doc.primary_slot[page_index] + if getattr(heading_page, "state_slot", False): + for block in heading_page.output_slot: + if page_index >= first.page.page_index - 1 and block.reading_order_index >= first.group_slot.reading_order_index: + break + if getattr(block, "is_body_paragraph", None): + acc += _info_weight(block.char_stats) + if acc >= 1000: + return [] + page_index += 1 + out: list[OutlineNode] = [] + parent_anchor = parent_node if (parent_node is not None and parent_node.heading.type == 5) else None + seen_signatures: set[str] = set() + style_cluster = StyleCluster() + saw_numbered = False + index = 0 + while index < len(cluster_candidates): + cluster_candidate = cluster_candidates[index] + if not ( + cluster_candidate.type == 5 + or cluster_candidate.type == 6 + or (cluster_candidate.type == 11 and cluster_candidate.has_numbering and parent_anchor is not None and index <= 1) + ): + next_item = cluster_candidates[index + 1] if index + 1 < len(cluster_candidates) else None + if next_item and next_item.type == 5 and next_item.page is cluster_candidate.page and next_item.tertiary_slot is cluster_candidate.tertiary_slot: + index += 1 + continue + break + candidate_signature = heading_signature(cluster_candidate) + if candidate_signature in seen_signatures: + index += 1 + continue + if should_reject_heading(state, cluster_candidate): + index += 1 + continue + push_heading_to_state(state, cluster_candidate) + seen_signatures.add(candidate_signature) + if cluster_candidate.has_numbering: + saw_numbered = True + elif saw_numbered: + break + if parent_anchor is None: + parent_anchor = OutlineNode(cluster_candidate) + out.append(parent_anchor) + style_cluster.add(cluster_candidate) + index += 1 + continue + anchor_heading_candidate = parent_anchor.heading + if cluster_candidate.page.page_index > anchor_heading_candidate.page.page_index: + break + cmp = compare_heading_depth(anchor_heading_candidate, cluster_candidate) + if cmp != 1: + if not has_style_neighbor(style_cluster, cluster_candidate, 1.0): + break + parent_anchor = OutlineNode(cluster_candidate) + out.append(parent_anchor) + style_cluster.add(cluster_candidate) + index += 1 + # Remove processed items so the outline loop does not reprocess them. + del cluster_candidates[:index] + if ( + len(out) >= 3 + or (len(out) == 2 and out[0].heading.has_numbering and out[1].heading.has_numbering) + ) and out[0].heading.type != 5: + return [] + return out + + +# --------------------------------------------------------------------------- # +# Flatten outline to top-level headings # +# --------------------------------------------------------------------------- # + + +def extract_top_level_headings(item_list: list[OutlineNode]) -> list[OutlineNode]: + """Walk the outline and emit top-level prominent headings.""" + out: list[OutlineNode] = [] + saw_prominent = False + for heading in item_list: + if heading.heading.is_prominent: + if not saw_prominent: + out.append(heading) + saw_prominent = True + else: + saw_prominent = False + out.extend(extract_top_level_headings(heading.child_nodes)) + return out + + +# --------------------------------------------------------------------------- # +# Outline validation. +# --------------------------------------------------------------------------- # + + +def is_outline_valid(doc, item_list: list[OutlineNode]) -> bool: + """Return True when top-level headings span a meaningful fraction of the document.""" + top = extract_top_level_headings(item_list) + if len(top) < 3: + return False + if len(top) >= 5: + return True + last_page = 1 + for top_node in top: + line = top_node.heading.page.page_index + if line - last_page > 0.5 * len(doc.primary_slot): + return False + last_page = line + return True + + +def is_chapter_outline_valid(doc, item_list: list[OutlineNode]) -> bool: + """Secondary validity check based on chapter count and inter-chapter span.""" + chapters = 0 + span = 0 + previous = -1 + for chapter_outline_node in item_list: + chapter_page = chapter_outline_node.heading.page.page_index + if previous >= 0: + span += chapter_page - previous + previous = -1 + if chapter_outline_node.heading.type == 8: + chapters += 1 + previous = chapter_page + if previous >= 0: + span += len(doc.primary_slot) - previous + 1 + return ( + chapters >= 3 + and span >= 0.7 * len(doc.primary_slot) + and span / max(1, chapters) < 100 + ) diff --git a/pageindex/flash/outline_assembly/style_context.py b/pageindex/flash/outline_assembly/style_context.py new file mode 100644 index 000000000..e80da375d --- /dev/null +++ b/pageindex/flash/outline_assembly/style_context.py @@ -0,0 +1,365 @@ +"""Style clusters, outline context/state, numbering trie, and depth comparison.""" + +from __future__ import annotations + +import math +from typing import Any, Callable, Optional + +from sortedcontainers import SortedKeyList +from ..model import ( + style_key, left_aligned, right_aligned, center_aligned, x_aligned, rect_union, + Rect, last_span, avg_char_width, raw_text_of_line, heading_score, numbering_text, numbering_value, numbering_kind, + reading_order_key, left_edge_key, _trim_unicode_ws, _round_half_up_to_int, Line, last_line_of, first_span_of, block_text, deaccented_text, letter_count, dominant_style_of, info_weight, dominant_font_size, is_upper_dominant, is_caps_heavy, alignment_code, Block, +) + +from .candidates import ( + HeadingCandidate, + compare_heading_order, + heading_order_key, + parent_signature, + cached_signature, + is_in_oo_range, + has_style_neighbor, +) + + +# --------------------------------------------------------------------------- # +# Font/style-clustered heading group # +# --------------------------------------------------------------------------- # + + +class StyleCluster: + """Group of headings sharing a font/style signature.""" + + __slots__ = ("auxiliary_slot", "state_slot", "secondary_slot", "primary_slot", "tertiary_slot") + + def __init__(self): + self.auxiliary_slot: dict[HeadingCandidate, str] = {} # candidate -> signature + self.state_slot: dict[str, HeadingCandidate] = {} # signature -> candidate + self.secondary_slot: SortedKeyList = SortedKeyList( + key=lambda sort_node: (heading_score(sort_node.group_slot), heading_order_key(sort_node)) + ) + self.primary_slot: Optional[HeadingCandidate] = None # min by heading order + self.tertiary_slot: Optional[HeadingCandidate] = None # max by heading order + + def size(self) -> int: + return len(self.secondary_slot) + + def contains(self, other_heading_candidate: HeadingCandidate) -> bool: + # Containment is key-based, not object identity. SortedKeyList's ``in`` + # tests identity among equal-key elements, so compare the sort keys. + idx = self.secondary_slot.bisect_left(other_heading_candidate) + return idx < len(self.secondary_slot) and self.secondary_slot.key(self.secondary_slot[idx]) == self.secondary_slot.key(other_heading_candidate) + + def add(self, other_heading_candidate: HeadingCandidate) -> None: + self.state_slot[cached_signature(self, other_heading_candidate)] = other_heading_candidate + # Keep set semantics over the sort key: equal-key elements are dropped, + # while the signature and min/max heading-order state still update. + idx = self.secondary_slot.bisect_left(other_heading_candidate) + if idx >= len(self.secondary_slot) or self.secondary_slot.key(self.secondary_slot[idx]) != self.secondary_slot.key(other_heading_candidate): + self.secondary_slot.add(other_heading_candidate) + if self.primary_slot is None or compare_heading_order(other_heading_candidate, self.primary_slot) < 0: + self.primary_slot = other_heading_candidate + if self.tertiary_slot is None or compare_heading_order(other_heading_candidate, self.tertiary_slot) > 0: + self.tertiary_slot = other_heading_candidate + + def has_nearby_duplicate(self, other_heading_candidate: HeadingCandidate) -> bool: + """"have we seen a nearby matching signature within +/- 20 pages?".""" + existing = self.state_slot.get(cached_signature(self, other_heading_candidate)) + return existing is not None and abs(other_heading_candidate.page.page_index - existing.page.page_index) < 20 + + +# --------------------------------------------------------------------------- # +# Outline-context style-bucket operations # +# --------------------------------------------------------------------------- # + + +def pick_style_bucket(outline_context: "OutlineContext", other_heading_candidate: HeadingCandidate) -> StyleCluster: + """Pick the right style bucket for a candidate.""" + if other_heading_candidate.type == 10: + return outline_context.secondary_slot + if other_heading_candidate.type == 8: + return outline_context.primary_slot + if len(other_heading_candidate.numbering) > 0: + return outline_context.auxiliary_slot + return outline_context.tertiary_slot + + +def has_conflict_in_context(outline_context: "OutlineContext", other_heading_candidate: HeadingCandidate) -> bool: + """Return True if a candidate conflicts with the existing outline context.""" + if other_heading_candidate.type != 10 and is_in_oo_range(outline_context.secondary_slot, other_heading_candidate): + return True + if other_heading_candidate.type != 8 and is_in_oo_range(outline_context.primary_slot, other_heading_candidate): + return True + if len(other_heading_candidate.numbering) <= 0 and is_in_oo_range(outline_context.auxiliary_slot, other_heading_candidate): + return True + if other_heading_candidate.type != 8 and len(other_heading_candidate.numbering) > 0 and outline_context.primary_slot.size() > 0: + return True + return False + + +def is_compatible_with_context(outline_context: "OutlineContext", other_heading_candidate: HeadingCandidate) -> bool: + """Return True iff a candidate can be added to the outline context.""" + if has_conflict_in_context(outline_context, other_heading_candidate): + return False + # Find the nearest predecessor by heading order. + text: Optional[HeadingCandidate] = None + for item in outline_context.state_slot: + if compare_heading_order(item, other_heading_candidate) <= 0: + if text is None or compare_heading_order(item, text) > 0: + text = item + else: + break + if text is not None: + candidate_block = other_heading_candidate.group_slot + previous_block = text.group_slot + if previous_block.isolated_centered and not candidate_block.isolated_centered: + return False + if not other_heading_candidate.is_prominent and heading_score(previous_block) > heading_score(candidate_block) + 0.5: + return False + if text.is_prominent and not other_heading_candidate.is_prominent and heading_score(previous_block) > heading_score(candidate_block) - 0.5: + return False + style_cluster = pick_style_bucket(outline_context, other_heading_candidate) + if not style_cluster.has_nearby_duplicate(other_heading_candidate) and has_style_neighbor(style_cluster, other_heading_candidate, 1.0): + return True + if len(other_heading_candidate.numbering) == 1 and has_style_neighbor(outline_context.tertiary_slot, other_heading_candidate, 1.0): + return True + return False + + +# --------------------------------------------------------------------------- # +# Outline-context group. +# --------------------------------------------------------------------------- # + + +class OutlineContext: + """Bundles style clusters for chapter, appendix, numbered, and general headings.""" + + __slots__ = ("secondary_slot", "primary_slot", "auxiliary_slot", "tertiary_slot", "state_slot") + + def __init__(self, headings: list[HeadingCandidate]): + self.secondary_slot = StyleCluster() # type == 10 + + self.primary_slot = StyleCluster() # type == 8 + + self.auxiliary_slot = StyleCluster() # has M (numbered) + self.tertiary_slot = StyleCluster() # everything else + # The ordered heading list is set-like by heading-order key, with the + # first equal-key candidate retained. + self.state_slot: list[HeadingCandidate] = [] + seen_keys: set = set() + for secondary_item in headings: + self.add(secondary_item) + key_value = heading_order_key(secondary_item) + if key_value not in seen_keys: + seen_keys.add(key_value) + self.state_slot.append(secondary_item) + self.state_slot.sort(key=heading_order_key) + + def add(self, other_heading_candidate: HeadingCandidate) -> None: + pick_style_bucket(self, other_heading_candidate).add(other_heading_candidate) + + def has_nearby_duplicate(self, other_heading_candidate: HeadingCandidate) -> bool: + return pick_style_bucket(self, other_heading_candidate).has_nearby_duplicate(other_heading_candidate) + + +# --------------------------------------------------------------------------- # +# Numbering-prefix tree. +# --------------------------------------------------------------------------- # + + +class NumberingTrie: + """a recursive map for numbering prefixes.""" + + __slots__ = ("primary_slot", "secondary_slot") + + def __init__(self): + self.primary_slot: dict[int, "NumberingTrie"] = {} + self.secondary_slot = 0 + + +def insert_numbering(primary_item: NumberingTrie, other_heading_candidate: HeadingCandidate, index: int) -> None: + """Insert the candidate numbering suffix into the trie.""" + if index == len(other_heading_candidate.numbering): + primary_item.secondary_slot += 1 + return + reference_item = primary_item.primary_slot.get(other_heading_candidate.numbering[index]) + if reference_item is None: + reference_item = NumberingTrie() + primary_item.primary_slot[other_heading_candidate.numbering[index]] = reference_item + insert_numbering(reference_item, other_heading_candidate, index + 1) + + +def count_sibling_numberings(primary_item: NumberingTrie, other_heading_candidate: HeadingCandidate, index: int) -> int: + """Count sibling numbering branches at the target depth.""" + if index >= len(other_heading_candidate.numbering) - 1: + count = 0 + for reference_item in primary_item.primary_slot.values(): + if reference_item.secondary_slot > 0: + count += 1 + return count + reference_item = primary_item.primary_slot.get(other_heading_candidate.numbering[index]) + if reference_item is None: + return 0 + return count_sibling_numberings(reference_item, other_heading_candidate, index + 1) + + +# --------------------------------------------------------------------------- # +# Global outline state. +# --------------------------------------------------------------------------- # + + +class OutlineState: + """Global state for outline assembly walks.""" + + __slots__ = ("state_slot", "cache_slot", "marker_slot", "previous_slot", "option_slot", "measure_slot", "style_slot", "secondary_slot", "auxiliary_slot", "tertiary_slot", "primary_slot") + + def __init__(self, clusters: list[dict]): + self.state_slot: dict = {} + self.cache_slot = NumberingTrie() + self.marker_slot: dict[str, StyleCluster] = {} + self.previous_slot = math.inf + self.option_slot = -math.inf + self.measure_slot = False + clusters_by_parent_signature: dict[str, list[StyleCluster]] = {} + for cluster in clusters: + heading_branch = cluster.get("labeled_anchor") + cluster_candidates = cluster.get("cluster_candidates", []) + if heading_branch is not None: + _apply_heading_to_state(self, heading_branch.heading) + for state_candidate in cluster_candidates: + _apply_heading_to_state(self, state_candidate) + if len(state_candidate.numbering) <= 0: + continue + key = parent_signature(state_candidate) + item_list = clusters_by_parent_signature.get(key) + if item_list is None: + item_list = [] + clusters_by_parent_signature[key] = item_list + placed = None + for style_cluster in item_list: + if not style_cluster.has_nearby_duplicate(state_candidate) and has_style_neighbor(style_cluster, state_candidate, 1.0): + placed = style_cluster + break + if placed is None and len(item_list) < 3: + placed = StyleCluster() + item_list.append(placed) + if placed is not None: + placed.add(state_candidate) + for key, group in clusters_by_parent_signature.items(): + group.sort(key=lambda bucket_group: -bucket_group.size()) + best = group[0] + if best.size() <= 2: + continue + self.marker_slot[key] = best + self.style_slot: list[Optional[HeadingCandidate]] = [] + self.secondary_slot = 0 + self.auxiliary_slot: Optional[HeadingCandidate] = None + self.tertiary_slot = 0 + self.primary_slot: Optional[HeadingCandidate] = None + + +def _apply_heading_to_state(state: OutlineState, other_heading_candidate: HeadingCandidate) -> None: + """Add a heading to the per-font tree and update document-level outline state.""" + seen: set[str] = set() + line = other_heading_candidate.group_slot.line() + for token_list in (other_heading_candidate.secondary_slot, other_heading_candidate.primary_slot): + if token_list is None: + continue + for token in token_list: + if token.line() is not line: + break + if token.type != 2: + continue + for anchor in token.anchor_ranges: + if anchor.line is not line: + break + span = anchor.anchor_span + style = style_key(span) + if style in seen: + continue + seen.add(style) + font_size = span.font_style() + tree = state.state_slot.get(font_size) + if tree is None: + tree = SortedKeyList( + key=lambda heading: (heading["size"], heading_order_key(heading["heading"])) + ) + state.state_slot[font_size] = tree + # Each per-font-size bucket is set-like by (size, heading-order) + # key, retaining the first equal-key entry. + entry = {"size": span.font_size, "heading": other_heading_candidate} + idx = tree.bisect_left(entry) + if idx >= len(tree) or tree.key(tree[idx]) != tree.key(entry): + tree.add(entry) + if other_heading_candidate.type == 1 and len(other_heading_candidate.numbering) > 0: + insert_numbering(state.cache_slot, other_heading_candidate, 0) + state.previous_slot = min(state.previous_slot, other_heading_candidate.page.page_index) + state.option_slot = max(state.option_slot, other_heading_candidate.page.page_index) + if not state.measure_slot: + state.measure_slot = other_heading_candidate.is_prominent + + +# --------------------------------------------------------------------------- # +# Pairwise heading-depth comparator # +# --------------------------------------------------------------------------- # + + +def compare_heading_depth(heading_candidate: HeadingCandidate, other_heading_candidate: HeadingCandidate, clique: Optional[StyleCluster] = None) -> int: + """Compare two heading candidates for relative nesting depth. Returns ``-1`` when the first candidate should be shallower, ``1`` when it should be deeper, and ``0`` when both candidates should share a level. The decision combines special heading types, numbering depth, structural numbering, style prominence, centered layout, clique membership, and bold weight. """ + special = heading_candidate.type in (8, 9, 10) + other_special = other_heading_candidate.type in (8, 9, 10) + if special and other_special: + return 0 + if special or other_special: + return 1 if special else -1 + if (heading_candidate.type == 1 and other_heading_candidate.type == 1) or (heading_candidate.type == 4 and other_heading_candidate.type == 4): + left_length = len(heading_candidate.numbering) + right_length = len(other_heading_candidate.numbering) + if left_length == right_length: + return 0 + return 1 if left_length < right_length else -1 + if heading_candidate.type == 2 and other_heading_candidate.type == 2: + return 0 + if heading_candidate.type == 11 and len(other_heading_candidate.numbering) == 1: + return -1 + heading_block = heading_candidate.group_slot + block = other_heading_candidate.group_slot + if heading_candidate.has_numbering != other_heading_candidate.has_numbering: + return -1 if heading_candidate.has_numbering else 1 + if heading_candidate.has_numbering and other_heading_candidate.has_numbering and abs(first_span_of(heading_block).font_size - first_span_of(block).font_size) < 0.9: + return 0 + score = heading_score(heading_block) + other_score = heading_score(block) + heading_in_clique = clique is not None and clique.contains(heading_candidate) + in_value = clique is not None and clique.contains(other_heading_candidate) + # Z-based major-gap return + if abs(score - other_score) > 1.9 or (abs(score - other_score) > 0.9 and (not heading_in_clique or not in_value)): + return 1 if score > other_score else -1 + # uppercase-dominant comparison + heading_caps_heavy = is_caps_heavy(heading_block) + caps_heavy = is_caps_heavy(block) + if heading_caps_heavy != caps_heavy: + return 1 if heading_caps_heavy else -1 + # paragraph-end / isolated-centered comparison + centered_flag = heading_block.isolated_centered + if centered_flag != block.isolated_centered: + return 1 if centered_flag else -1 + # skew (rotation) comparison -- skipped if both type 5 + if not (heading_candidate.type == 5 and other_heading_candidate.type == 5): + heading_skewed = heading_block.previous_slot > 0.99 + skew = block.previous_slot > 0.99 + if heading_skewed != skew: + return -1 if heading_skewed else 1 + # uppercase or both in clique -> tied + if heading_caps_heavy or (heading_in_clique and in_value): + return 0 + # clique containment asymmetric + if (heading_in_clique and other_heading_candidate.type != 4) or (in_value and heading_candidate.type != 4): + return 1 if heading_in_clique else -1 + # Bold comparison. + bold = heading_block.bold_frac() > 0.5 + other_bold = block.bold_frac() > 0.5 + if bold != other_bold: + return 1 if bold else -1 + return 0 diff --git a/pageindex/flash/parser_pdfium_charlevel/__init__.py b/pageindex/flash/parser_pdfium_charlevel/__init__.py new file mode 100644 index 000000000..68ccffdd6 --- /dev/null +++ b/pageindex/flash/parser_pdfium_charlevel/__init__.py @@ -0,0 +1,168 @@ +"""PDFium-backed text-item reconstruction via textpage chars and bbox-mapped font handles. + +The parser reconstructs content-stream text items from rendered characters while +preserving the geometry needed by downstream line clustering and heading +detection. The merge thresholds operate on glyph advance, font size, text +matrix scale, and spacing introduced by char spacing, text-position operators, +and ``TJ`` adjustments. + +Per page, the reconstruction uses rendered character origins, glyph widths, +font bbox containment, effective font size, text-item merging, baseline-anchored +character boxes, and the minimum font size derived in each emitted chunk. Those +calibrations keep small caps, math glyphs, ligatures, Type 3 fonts, rotated +text, and vertical writing stable enough for layout statistics. +""" + +import bisect +import ctypes +import difflib +import json +import math +import re +import unicodedata +from collections import Counter +from io import BytesIO +from pathlib import Path +from typing import Union + +import pypdfium2 as pdfium +import pypdfium2.raw as pdfium_c + +# Raw PDF object access (ToUnicode CMaps, content streams, font dicts, /WMode) +# that PDFium does not expose, read via PyPDF2 -- already a project dependency and +# permissively licensed. A thin adapter exposes the small raw-object API the +# helpers below need, so their calibrated logic stays unchanged. +import PyPDF2 as _pypdf2 # declared dependency (also imported by pageindex.utils/client) +from PyPDF2.generic import ( + IndirectObject as PdfIndirectRef, NameObject as PdfName, NumberObject as PdfNumber, + FloatObject as PdfFloat, BooleanObject as PdfBoolean, + DictionaryObject as PdfDictionary, ArrayObject as PdfArray, +) + +from ..model import Span, Rect + +from .pdf_objects import ( + _pdf_tok, + _pdf_obj_str, + _pdf_typed, + _PdfPage, + _PdfDoc, + _PDF_WHITESPACE_BYTES, + _PDF_DELIMITER_BYTES, + _PDF_STRING_ESCAPE_BYTES, + _decode_pdf_name, +) +from .text_normalize import ( + _DROP_CHARS, + _NORMALIZED_UNICODES, + _normalize_unicodes, + TRACKING_SPACE_FACTOR, + NON_SPACE_GAP_FACTOR, + NEGATIVE_SPACE_FACTOR, + SPACE_IN_FLOW_MIN_FACTOR, + SPACE_IN_FLOW_MAX_FACTOR, + _WHITESPACE_CODEPOINTS, + _is_whitespace, + _is_zero_width_diacritic, + _is_invisible_format_mark, + _BIDI_BASE_TYPES, + _BIDI_ARABIC_TYPES, + _apply_bidi_reordering, + _rtl_sign, + _reverse_if_rtl, + _read_end, + _read_gap, +) +from .content_stream import ( + _FLUSH_OPS, + _SHOW_OPS, + _OP_LEX_PREFIX, + _OP_OPERAND_COUNTS, + _tokenize_show_operators, + _assign_vertical_tags, + _assign_show_tz, + _page_vertical_resource_names, +) +from .glyph_tables import ( + _GLYPHLIST_PATH, + _cached_glyphs, + _cached_encodings, + _load_glyph_tables, + _get_unicode_for_glyph, + _from_char_code, +) +from .cmap_parse import ( + _utf16be_units_to_str, + _NUM_DECIMAL_RE, + _NUM_INFINITY_RE, + _NUM_HEX_RE, + _NUM_OCTAL_RE, + _NUM_BINARY_RE, + _WHITESPACE_STRIP, + _ieee_div, + _compute_skew, + _to_number, + _parse_int, + _cmap_str_to_int, + _parse_tounicode_cmap, +) +from .font_unicode import ( + _TYPE1_SPECIAL_BYTES, + _TYPE1_WHITESPACE_BYTES, + _type1_builtin_encoding, + _simple_font_to_unicode, + _font_unicode_map, +) +from .code_walk import ( + _resource_dict_xrefs, + _page_show_codes, + _char_category, + _walk_codes, +) +from .unicode_apply import ( + _apply_font_unicode, + _synthesize_dropped_glyphs, +) +from .geometry import ( + _obj_rotation, + _xf_point, + _compose_mtx, + _IDENT_MTX, + _collect_text_objs, + _build_obj_index, + _char_render_fs, + _find_obj_for_char, +) +from .char_extract import ( + _extract_raw_chars, + _accumulate_type3_extents, + _type3_size_by_font, + _apply_type3_sizes, + _finalize_chars, + _inherited_box, + _page_view_rect, + _off_page, +) +from .merge import _merge_text_items +from .remerge import ( + _start_rot_span, + _grow_rot_span, + _merge_rotated_one, + _remerge_rotated, + _new_oblique_span, + _close_oblique, + _oblique_space, + _merge_oblique_one, + _remerge_oblique, + _start_vert_span, + _close_vert_span, + _merge_vertical_one, + _grow_vert_span, + _remerge_vertical, +) +from .pipeline import ( + parse_charlevel_meta, + parse_charlevel, +) + +__all__ = ["parse_charlevel", "parse_charlevel_meta"] diff --git a/pageindex/flash/parser_pdfium_charlevel/char_extract.py b/pageindex/flash/parser_pdfium_charlevel/char_extract.py new file mode 100644 index 000000000..6d497c5ea --- /dev/null +++ b/pageindex/flash/parser_pdfium_charlevel/char_extract.py @@ -0,0 +1,363 @@ +"""Raw textpage char extraction, Type3 sizing, and page viewport handling.""" + +from __future__ import annotations + +import ctypes +import pypdfium2.raw as pdfium_c + +from .text_normalize import ( + _is_whitespace, + _is_zero_width_diacritic, + _is_invisible_format_mark, +) +from .geometry import ( + _collect_text_objs, + _build_obj_index, + _find_obj_for_char, +) + + +def _extract_raw_chars(page, text_page) -> tuple[list[dict], list[dict]]: + """First pass: walk textpage chars and attach font info via the bbox-containing text-object lookup. Returns ``(raw_chars, objects)``; glyph widths and the identity-matrix Type-3 size override are applied later, after document-wide Type-3 extents are known.""" + objects = _collect_text_objs(page, text_page) + if not objects: + return [], [] + obj_index = _build_obj_index(objects) + + # First pass: collect raw textpage chars with their host obj. + count_item = pdfium_c.FPDFText_CountChars(text_page) + font_name_buffer = (ctypes.c_char * 256)() + flags = ctypes.c_int(0) + field = ctypes.c_float(0) + raw_chars: list[dict] = [] + last_obj: dict | None = None + for index_value in range(count_item): + codepoint = pdfium_c.FPDFText_GetUnicode(text_page, index_value) + if codepoint < 0: + continue + # u == 0 (PDFium found no unicode for the glyph) is KEPT as '\x00': + # text extraction emits the raw charcode for unmapped codes, so its items + # really contain chr(0) for extension-font pieces at code 0, and the + # textpage char carries normal geometry. Skipping it lost the char AND desynced + # the unicode walk's object pairing around it. + ch_str = chr(codepoint) + is_gen = bool(pdfium_c.FPDFText_IsGenerated(text_page, index_value)) + # PDFium inserts is_generated chars as layout placeholders for + # Td/Tm jumps with no literal content-stream char (typically + # " ", "\r", "\n"). Dropping them outright leaves an + # unexplained advance gap that the merger then turns into a + # fake-space chunk, splitting e.g. "2.1 Computing the EMD" + # into three spans (2.1, " ", Computing the EMD) that pipeline + # treats as a numeric prefix alone (not a heading). Keep + # generated whitespace so the merger's whitespace branch fires + # save_last_char without emitting, letting the next visible + # glyph compute a tracking-size in-flow advance. Drop only + # non-whitespace generated chars (very rare). + if is_gen and not _is_whitespace(codepoint): + continue + char_origin_x = ctypes.c_double(0) + char_origin_y = ctypes.c_double(0) + pdfium_c.FPDFText_GetCharOrigin(text_page, index_value, ctypes.byref(char_origin_x), ctypes.byref(char_origin_y)) + + # Fetch char bbox first so we can use its center for the obj + # lookup — origin alone fails when adjacent obj bboxes nearly + # touch (e.g. math-heavy page "(", math italic font \x01, ")" all on the same line + # with sub-pt gaps, where origin x falls inside the wrong obj's + # tolerance window). Using bbox center gives unambiguous + # containment. + char_left_box = ctypes.c_double(0); char_right_box = ctypes.c_double(0) + value = ctypes.c_double(0); char_top_box = ctypes.c_double(0) + pdfium_c.FPDFText_GetCharBox( + text_page, index_value, + ctypes.byref(char_left_box), ctypes.byref(char_right_box), + ctypes.byref(value), ctypes.byref(char_top_box), + ) + char_left, char_right, char_top, char_bottom = char_left_box.value, char_right_box.value, char_top_box.value, value.value + # Tight (ink) box center -> font-object disambiguation only. + center_x = (char_left + char_right) / 2 if char_right > char_left else char_origin_x.value + center_y = (char_top + char_bottom) / 2 if char_top > char_bottom else char_origin_y.value + # Horizontal extent for the SPAN comes from the LOOSE char box (the + # glyph's full advance cell), not the tight ink box. the PDF text-item + # widths are advance-based; the ink box undershoots each glyph's right + # edge by its side bearing (e.g. "]" ink-right 274.0 vs advance 275.2, + # as expected for advance-based text items). Using the ink box cumulatively under-fills + # display-math gaps so the column detector mis-reads them as gutters + # and splits a line ("E[x] = μ" -> "E[x]" fragment). Fall back to the + # ink box if the loose box is unavailable/degenerate. + loose_box = pdfium_c.FS_RECTF(0, 0, 0, 0) + if (pdfium_c.FPDFText_GetLooseCharBox(text_page, index_value, ctypes.byref(loose_box)) + and loose_box.right > loose_box.left): + loose_left, loose_right = loose_box.left, loose_box.right + # Vertical edges of the loose (advance-cell) box. For vertical- + # writing (Identity-V / WMode 1) text PDFium builds this cell by + # advancing -y from the PEN, so its upper edge IS the pen y and + # its extent IS the per-char vertical advance (W2/DW2 applied by + # PDFium itself). PDFium fills top/bottom in flow order here, so + # they arrive inverted (top < bottom); keep both raw edges. + cell_top, cell_bottom = loose_box.top, loose_box.bottom + else: + loose_left, loose_right = char_left, char_right + cell_top, cell_bottom = char_top, char_bottom + + # Character font size disambiguates overlapping objects, such as large + # figure labels sharing a y range with smaller heading text. + char_fs_tp = pdfium_c.FPDFText_GetFontSize(text_page, index_value) + # text-page and character-index lookup read the true per-char rendered size + # (FPDFText_GetMatrix, == text extraction font size) lazily, only to break a + # multi-object containment tie — see _find_obj_for_char. + obj = _find_obj_for_char( + obj_index, center_x, center_y, tol=1.0, char_fs=char_fs_tp, text_page=text_page, char_idx=index_value + ) + if obj is None: + obj = ( + _find_obj_for_char(obj_index, char_origin_x.value, char_origin_y.value, tol=1.0, + char_fs=char_fs_tp, text_page=text_page, char_idx=index_value) + or _find_obj_for_char(obj_index, char_origin_x.value, char_origin_y.value, tol=5.0, + char_fs=char_fs_tp, text_page=text_page, char_idx=index_value) + or last_obj + ) + if obj is None: + continue + last_obj = obj + + name = pdfium_c.FPDFText_GetFontInfo(text_page, index_value, font_name_buffer, 256, ctypes.byref(flags)) + char_font_name = ( + bytes(font_name_buffer[:name]).decode("latin-1", errors="replace").rstrip("\x00") + if name > 1 else obj["font_name"] + ) + + # Use baseline (oy) as bbox bottom and baseline + fs_eff as top. + # the span anchoring rule uses matrix.f (= baseline y) for both top/ + # bottom anchors of its span, so chars of the same line all + # land at the same bottom even when their ink extends below + # baseline ("(", "g", "y" with descenders) or above ("\x01" + # math glyphs). This is what the heading heuristics' tokenizer assumes when + # checking |c1.C - c2.C| < 1 to decide whether two spans are on + # the same line. + baseline_y = char_origin_y.value + char_top = baseline_y + obj["fs_eff"] + # Capture the raw glyph advance now, while this page (and thus the + # font handle) is alive. The fs_eff-dependent scaling happens later + # in _finalize_chars, after the document-wide Type-3 size is known, + # so deferring the call would require keeping every page open just to + # keep font handles valid (PDFium frees the font when the page is + # closed -> dangling handle). + pdfium_c.FPDFFont_GetGlyphWidth( + obj["font"], ctypes.c_uint32(codepoint), + ctypes.c_float(obj["fs_raw"]), ctypes.byref(field), + ) + raw_chars.append({ + "i": index_value, "ch": chr(codepoint), "u": codepoint, + "is_gen": is_gen, + "is_ws": _is_whitespace(codepoint), + "is_mn": _is_zero_width_diacritic(codepoint), + "is_cf": _is_invisible_format_mark(codepoint), + "ox": char_origin_x.value, "oy": char_origin_y.value, + "left": loose_left, "right": loose_right, + "top": char_top, "bottom": baseline_y, + "box_top": char_top, + "box_bottom": char_bottom, + "cell_top": cell_top, "cell_bot": cell_bottom, + "w_raw": field.value, + "obj": obj, "font_name": char_font_name, + }) + + return raw_chars, objects + + +def _accumulate_type3_extents(raw_chars: list[dict], acc: dict) -> None: + """Accumulate document-wide per-font glyph-bbox extents for identity-matrix Type-3 fonts. These fonts use a synthesized font bbox from the union of CharProc glyph boxes and render every glyph at that uniform height. PDFium reports a constant font size and identity CTM for these fonts, but its char box returns each glyph's declared bounds exactly, so box-top/bottom relative to the baseline reveal the rendered glyph extents. Aggregating across the whole document makes the font sizing coverage-independent; a per-page union would drift with sparse page content. Scoped to the identity-matrix Type-3 branch so normal and scaled-matrix fonts are untouched.""" + for candidate_item in raw_chars: + item_value = candidate_item["obj"] + if item_value["fs_raw"] >= 1.5 or item_value["scale_y"] >= 1.5 or candidate_item["is_ws"]: + continue + top = candidate_item["box_top"] - candidate_item["oy"] + bot = candidate_item["box_bottom"] - candidate_item["oy"] + if top <= bot: # degenerate glyph box (text extraction skips d1 i==0) + continue + _xref_key = ctypes.cast(item_value["font"], ctypes.c_void_p).value + entry_item = acc.get(_xref_key) + if entry_item is None: + acc[_xref_key] = [top, bot] + else: + if top > entry_item[0]: + entry_item[0] = top + if bot < entry_item[1]: + entry_item[1] = bot + + +def _type3_size_by_font(acc: dict) -> dict: + """font handle -> rendered font.bbox height = max ascent - min descent, i.e. span merger ``a = font.bbox[3] - font.bbox[1]`` in page units. Snap to the shortest decimal (PDFium float32 vs span merger float64) for clean knife-edge size comparisons downstream (the page-median gate).""" + out: dict = {} + for _xref_key, (top, bot) in acc.items(): + if top > bot: + out[_xref_key] = float(f"{top - bot:.6g}") + return out + + +def _apply_type3_sizes(raw_chars: list[dict], size_by_font: dict) -> None: + """Override fs_eff with the document-wide Type-3 size and reset each char's span top to baseline + that size.""" + if not size_by_font: + return + for candidate_item in raw_chars: + item_value = candidate_item["obj"] + if item_value["fs_raw"] >= 1.5 or item_value["scale_y"] >= 1.5: + continue + font_size_value = size_by_font.get(ctypes.cast(item_value["font"], ctypes.c_void_p).value) + if font_size_value: + item_value["fs_eff"] = font_size_value + candidate_item["top"] = candidate_item["oy"] + font_size_value + + +def _finalize_chars(raw_chars: list[dict]) -> list[dict]: + """Second pass: compute glyph_w per char and emit the merged-ready dicts. The right glyph width definition depends on how PDFium reports the font's metrics: (a) Normal Type 1 fonts (fs_raw >= 1.5, scale.a ~= 1): FPDFFont_GetGlyphWidth(font, code, fs_raw) returns the advance in page units. Use as-is x matrix.a. (b) Scaled-matrix Type 3 (fs_raw < 1.5 but matrix scale >= 1.5, e.g. vector-heavy page's a scaled Type-3 subset with scale=36.49): GetGlyphWidth at fs_raw=0.19 gives font-natural-unit width; x matrix scale recovers page units. (c) Identity-matrix Type 3 (fs_raw < 1.5, matrix.a ~= 1, e.g. identity-matrix Type-3 sample an identity-matrix Type-3 font): GetGlyphWidth's output is wrong by an unknown FontMatrix factor (PDFium doesn't fold this for these fonts). Fall back to neighbor-step fallback (next_char.ox - this_char.ox within same obj). """ + out: list[dict] = [] + for key_value, candidate_item in enumerate(raw_chars): + if candidate_item.get("drop"): + # Folded into the previous char by _apply_font_unicode (PDFium's + # decomposition of a glyph text extraction emits as ONE precomposed char). + continue + obj = candidate_item["obj"] + # w_raw = FPDFFont_GetGlyphWidth(font, code, fs_raw), captured in the + # first pass while the page/font handle was alive. + raw = candidate_item["w_raw"] + if "w_synth" in candidate_item: + # Synthesized glyph (PDFium font-layer drop): the advance was + # computed from the surviving neighbors' pen gap. + glyph_w = candidate_item["w_synth"] + elif obj["fs_raw"] >= 1.5 or obj["scale_y"] >= 1.5: + # Cases (a) and (b): GetGlyphWidth + matrix scaling works. + glyph_w = raw * obj["scale_x"] + else: + # Case (c): Identity-matrix Type 3 — derive from neighbor. + nxt = raw_chars[key_value + 1] if key_value + 1 < len(raw_chars) else None + if ( + nxt is not None + and nxt["obj"] is obj + and abs(nxt["oy"] - candidate_item["oy"]) < 0.5 + and nxt["ox"] > candidate_item["ox"] + ): + glyph_w = nxt["ox"] - candidate_item["ox"] + else: + # Last char in obj or new line — scale by fs_eff/fs_raw. + scale = (obj["fs_eff"] / obj["fs_raw"]) if obj["fs_raw"] > 0 else 1.0 + glyph_w = raw * scale + + # NOTE: glyph_w is PDFium's FPDFFont_GetGlyphWidth, used by the extraction advance model + # the font's glyph width; this is the advance model. For some RTL + # (Hebrew/Arabic) fonts PDFium's GetGlyphWidth does not match the actual + # rendered char spacing, which leaves spurious intra-word spaces; that is + # a PDF backend DATA LIMITATION (PDFium's hmtx/advance reporting), + # not a condition to compensate for here (any positional override conflates + # glyph advance with TJ word-gaps and breaks shaped Arabic). Left as-is. + + reference_item = { + "ch": candidate_item["ch"], + "is_ws": candidate_item["is_ws"], + "is_mn": candidate_item["is_mn"], + "is_cf": candidate_item["is_cf"], + "ox": candidate_item["ox"], "oy": candidate_item["oy"], + "glyph_w": glyph_w, + "fs": obj["fs_eff"], + "fs_x": obj["fs_raw"] * obj["scale_x"] if obj["scale_x"] > 0 else obj["fs_eff"], + # Left edge from the text-positioning pen origin (ox), matching + # span merger, not the glyph ink box: the ink-box left drifts ~0.1pt by + # first-glyph side bearing, which trips the column-alignment gate + # gate (tol 0.1) and over-splits double-spaced blocks. Right stays + # ink-box (pen-right via glyph_w is unreliable for Type-3 fonts). + "left": candidate_item["ox"], "right": candidate_item["right"], + "top": candidate_item["top"], "bottom": candidate_item["bottom"], + "font_name": candidate_item["font_name"], + # Unique per-font identity (the PDFium font handle, == span merger' + # loaded font identity). The merger splits chunks on this, not on font_name: + # identity-matrix Type-3 fonts (an identity-matrix Type-3 font) all report an + # empty name, so a name-based split can't separate a 12pt body run + # from an inline 11pt code word ("...of expressions..."). span merger + # emits a separate text item per font, so the body keeps fs=12 and + # the code word fs=11.16 instead of the whole run collapsing to the + # smaller fs_min. + "font_key": ctypes.cast(obj["font"], ctypes.c_void_p).value, + "weight": obj["weight"], + "obj": obj, # host text object (Tj/show-text) + } + if obj.get("vertical"): + # Vertical-writing pen model, from the loose advance cell (probe- + # for Identity-V: cell upper edge == pen y, cell extent == + # the per-char vertical advance with W2/DW2 applied by PDFium, and + # the cell is horizontally centred on the pen x because the default + # vertical origin vx is w/2 -- the default vertical-origin convention when the + # font has no per-char vmetric). + pen_y = max(candidate_item["cell_top"], candidate_item["cell_bot"]) + reference_item["v_pen_x"] = (candidate_item["left"] + candidate_item["right"]) / 2.0 + reference_item["v_pen_y"] = pen_y + # pen y after this glyph's advance (text extraction previous glyph transform[5]) + reference_item["v_after"] = min(candidate_item["cell_top"], candidate_item["cell_bot"]) + out.append(reference_item) + return out + + +def _inherited_box(pdf_doc, page_idx: int, name: str): + """span merger ``inherited page-box lookup`` definition: MediaBox/CropBox resolved through the page-tree ``/Parent`` chain (page-tree inheritance lookup). PDFium's FPDFPage_Get*Box does NOT inherit (pdfium bug 1786), so inherited boxes must come from the PyPDF2 channel. Returns a raw 4-tuple or None (absent / not a 4-number array, matching span merger length gate).""" + try: + xref_cursor = pdf_doc.page_xref(page_idx) + for _ in range(32): + token_value, value = pdf_doc.xref_get_key(xref_cursor, name) + if token_value != "null": + if token_value != "array": + return None + box_tokens = value.strip().lstrip("[").rstrip("]").split() + if len(box_tokens) != 4: + return None # span merger: array check and length == 4 + + try: + return tuple(float(box_token) for box_token in box_tokens) + except ValueError: + return None + parent_key_type, position_value = pdf_doc.xref_get_key(xref_cursor, "Parent") + if parent_key_type != "xref": + return None + xref_cursor = int(position_value.split()[0]) + except Exception: + return None + return None + + +def _page_view_rect(page, med_raw=None, crop_raw=None) -> tuple[float, float, float, float] | None: + """span merger ``normalized page view`` : rectangle normalization'd CropBox clamped to the rectangle normalization'd MediaBox. Differing boxes are intersected (rectangle intersection); an empty or zero-area intersection, and a degenerate CropBox, fall back to the MediaBox (a degenerate MediaBox falls back to US-Letter, text extraction US-Letter fallback media box). ``med_raw``/``crop_raw`` are the INHERITED boxes from ``_inherited_box`` (None = absent/no reader); the PDFium getters below are the non-inheriting fallback.""" + def norm(secondary_item): + if secondary_item is None: + return None + box_x_min, box_y_min, box_x_max, box_y_max = secondary_item + count_item = (min(box_x_min, box_x_max), min(box_y_min, box_y_max), max(box_x_min, box_x_max), max(box_y_min, box_y_max)) + return count_item if (count_item[2] - count_item[0] > 0 and count_item[3] - count_item[1] > 0) else None + + med = norm(med_raw) + if med is None: + try: + med = norm(tuple(page.get_mediabox())) + except Exception: + med = None + if med is None: + med = (0.0, 0.0, 612.0, 792.0) + crop = norm(crop_raw) + if crop is None: + try: + crop = norm(tuple(page.get_cropbox())) + except Exception: + crop = None + if crop is None or crop == med: + return med + x_min, y_min = max(crop[0], med[0]), max(crop[1], med[1]) + x_max, y_max = min(crop[2], med[2]), min(crop[3], med[3]) + if x_max - x_min <= 0 or y_max - y_min <= 0: + return med + return (x_min, y_min, x_max, y_max) + + +def _off_page(mapping: dict, view_box) -> bool: + """Position-comparison view box test: a non-diacritic glyph whose text origin is outside the page view box is dropped. The check compares ``pos - view box origin`` against the raw x1/y1 upper bounds, not width/height. ``view_box`` is the normalized page view as ``(x0, y0, x1, y1)``; None disables the test.""" + if view_box is None: + return False + origin_offset_x = mapping["ox"] - view_box[0] + origin_offset_y = mapping["oy"] - view_box[1] + return origin_offset_x < 0 or origin_offset_x > view_box[2] or origin_offset_y < 0 or origin_offset_y > view_box[3] diff --git a/pageindex/flash/parser_pdfium_charlevel/cmap_parse.py b/pageindex/flash/parser_pdfium_charlevel/cmap_parse.py new file mode 100644 index 000000000..26733d125 --- /dev/null +++ b/pageindex/flash/parser_pdfium_charlevel/cmap_parse.py @@ -0,0 +1,316 @@ +"""PostScript number parsing and ToUnicode CMap interpretation.""" + +from __future__ import annotations + +import math +import re + +from .pdf_objects import ( + _PDF_WHITESPACE_BYTES, + _PDF_DELIMITER_BYTES, + _PDF_STRING_ESCAPE_BYTES, +) +from .text_normalize import _WHITESPACE_CODEPOINTS + + +def _utf16be_units_to_str(units: list[int]) -> str: + """Decode UTF-16BE token bytes into text. Odd trailing bytes pair with 0. A unit can exceed 0xFF during range carry, and no byte mask is applied before surrogate handling, so a composed value may exceed 0xFFFF and become an astral character.""" + if len(units) % 2: + units = units + [0] + out: list[int] = [] + key_value = 0 + while key_value < len(units): + width_one = (units[key_value] << 8) | units[key_value + 1] + key_value += 2 + if (width_one & 0xF800) != 0xD800: + out.append(width_one) + continue + width_two = 0 + if key_value < len(units): + width_two = (units[key_value] << 8) | units[key_value + 1] + key_value += 2 + out.append(((width_one & 0x3FF) << 10) + (width_two & 0x3FF) + 0x10000) + return "".join(chr(candidate_item) for candidate_item in out) + + +# ASCII-only numeric grammar used for PDF numeric-name heuristics. It uses the +# same decimal grammar as model.to_number but without NFKC normalization. Trim set is the +# Unicode WhiteSpace + LineTerminator set, not Python's str.strip set. +_NUM_DECIMAL_RE = re.compile(r"^[+-]?(?:[0-9]+\.?[0-9]*|\.[0-9]+)(?:[eE][+-]?[0-9]+)?$") +_NUM_INFINITY_RE = re.compile(r"^[+-]?Infinity$") +_NUM_HEX_RE = re.compile(r"^0[xX][0-9a-fA-F]+$") +_NUM_OCTAL_RE = re.compile(r"^0[oO][0-7]+$") +_NUM_BINARY_RE = re.compile(r"^0[bB][01]+$") +_WHITESPACE_STRIP = "".join(chr(unit_value) for unit_value in _WHITESPACE_CODEPOINTS) + + +def _ieee_div(value: float, other_item: float) -> float: + """IEEE-754 division, no ZeroDivisionError (``0/0-> NaN, ``x/±0-> ±Inf with the usual sign rules).""" + if other_item != 0.0: + return value / other_item + if value == 0.0 or value != value: + return math.nan + return math.inf if (value > 0.0) == (math.copysign(1.0, other_item) > 0.0) else -math.inf + + +def _compute_skew(mtx: tuple) -> float: + """Return the text matrix skew score for an item. transform's rotation/shear ratios, no zero guard (cardinal rotation -> Inf, upright -> 0). Degenerate case: the matrix-size path folds font size into the transform, so ``Tf 0`` text gives 0/0 = NaN there; the PDFium object matrix keeps font size separate and yields finite ratios (degenerate invisible text only).""" + primary_item, secondary_item, candidate_item, reference_item = mtx + quad_one = _ieee_div(secondary_item, primary_item) + quad_two = _ieee_div(candidate_item, reference_item) + return quad_one * quad_one + quad_two * quad_two + + +def _to_number(text: str) -> float: + """/ ``numeric conversion`` (no NFKC): trim parser whitespace, ``""-> 0, then the numeric literal grammar (decimal/exponent, ``0x``/``0o``/``0b``, ``+-Infinity``); anything else -> NaN.""" + token_value = text.strip(_WHITESPACE_STRIP) + if token_value == "": + return 0.0 + if _NUM_INFINITY_RE.match(token_value): + return -math.inf if token_value[0] == "-" else math.inf + if _NUM_HEX_RE.match(token_value): + return float(int(token_value[2:], 16)) + if _NUM_OCTAL_RE.match(token_value): + return float(int(token_value[2:], 8)) + if _NUM_BINARY_RE.match(token_value): + return float(int(token_value[2:], 2)) + if _NUM_DECIMAL_RE.match(token_value): + return float(token_value) + return math.nan + + +def _parse_int(text: str, radix: int) -> float: + """skip leading parser whitespace, an optional sign, an optional ``0x`` prefix when ``radix == 16``, then the leading run of radix digits. Returns ``NaN`` (as in the heading heuristics) when no digit is consumed.""" + token_value = text.lstrip(_WHITESPACE_STRIP) + index_value = 0 + neg = False + if index_value < len(token_value) and token_value[index_value] in "+-": + neg = token_value[index_value] == "-" + index_value += 1 + if radix == 16 and token_value[index_value:index_value + 2] in ("0x", "0X"): + index_value += 2 + digits = "0123456789abcdefghijklmnopqrstuvwxyz"[:radix] + start = index_value + val = 0 + while index_value < len(token_value) and token_value[index_value].lower() in digits: + val = val * radix + digits.index(token_value[index_value].lower()) + index_value += 1 + if index_value == start: + return math.nan + return float(-val if neg else val) + + +def _cmap_str_to_int(seq) -> int: + """Accumulate CMap definition-code bytes with 32-bit unsigned wrap.""" + primary_item = 0 + for codepoint in seq: + primary_item = ((primary_item << 8) | codepoint) & 0xFFFFFFFF + return primary_item + + +def _parse_tounicode_cmap(data: bytes) -> dict[int, str]: + """CMap reader for ToUnicode streams, following text extraction CMap parsing + ToUnicode parsing: bfchar/bfrange with hex, literal-string, and (bfrange dst / array elements) integer tokens, plus cidchar/cidrange (numeric entries -> code-point conversion, the numeric-CID class). Structural junk is contained per block like CMap parsing's warn-and-continue catch (the block is dropped, the map survives); only decode-level errors (chr on a code-point conversion-invalid value) propagate so the caller reaches span merger ToUnicode parsing rejection path (-> no included map).""" + tokens: list = [] + index_value, count_item = 0, len(data) + while index_value < count_item: + candidate_item = data[index_value] + if candidate_item in _PDF_WHITESPACE_BYTES: + index_value += 1 + elif candidate_item == 0x25: # comment + while index_value < count_item and data[index_value] not in b"\r\n": + index_value += 1 + elif candidate_item == 0x3C: # << dict-open (skip) or + if index_value + 1 < count_item and data[index_value + 1] == 0x3C: + index_value += 2 + continue + state_item = data.find(b">", index_value) + if state_item < 0: + break # unterminated hex string: stop and keep tokens already read + hex_values = "".join(chr(secondary_item) for secondary_item in data[index_value + 1:state_item] + if chr(secondary_item) in "0123456789abcdefABCDEF") + if len(hex_values) % 2: + hex_values = hex_values[:-1] # drop a lone trailing hex digit + tokens.append(("hex", tuple(bytes.fromhex(hex_values)))) + index_value = state_item + 1 + elif candidate_item == 0x3E: # >> dict-close (skip) + index_value += 2 if (index_value + 1 < count_item and data[index_value + 1] == 0x3E) else 1 + elif candidate_item in b"[]": + tokens.append(("delim", chr(candidate_item))) + index_value += 1 + elif candidate_item == 0x2F: # /name + state_item = index_value + 1 + while state_item < count_item and data[state_item] not in _PDF_WHITESPACE_BYTES and data[state_item] not in _PDF_DELIMITER_BYTES: + state_item += 1 + tokens.append(("name", data[index_value + 1:state_item].decode("latin-1"))) + index_value = state_item + elif candidate_item == 0x28: # (string) -- literal-string lexer code units (dst values) + depth = 0 + unicode_scalar: list[int] = [] + while index_value < count_item: + byte_value = data[index_value] + if byte_value == 0x5C: + if index_value + 1 >= count_item: + index_value += 1 + break + entry_item = data[index_value + 1] + if entry_item in _PDF_STRING_ESCAPE_BYTES: + unicode_scalar.append(_PDF_STRING_ESCAPE_BYTES[entry_item]) + index_value += 2 + elif 0x30 <= entry_item <= 0x37: + state_item = index_value + 1 + val = 0 + while state_item < count_item and state_item - index_value <= 3 and 0x30 <= data[state_item] <= 0x37: + val = (val << 3) | (data[state_item] - 0x30) + state_item += 1 + unicode_scalar.append(val) + index_value = state_item + elif entry_item in (0x0D, 0x0A): + index_value += 2 + if entry_item == 0x0D and index_value < count_item and data[index_value] == 0x0A: + index_value += 1 + else: + unicode_scalar.append(entry_item) + index_value += 2 + continue + if byte_value == 0x28: + if depth: + unicode_scalar.append(byte_value) + depth += 1 + elif byte_value == 0x29: + depth -= 1 + if depth == 0: + index_value += 1 + break + unicode_scalar.append(byte_value) + else: + unicode_scalar.append(byte_value) + index_value += 1 + tokens.append(("hex", tuple(unicode_scalar))) + else: + state_item = index_value + while state_item < count_item and data[state_item] not in _PDF_WHITESPACE_BYTES and data[state_item] not in _PDF_DELIMITER_BYTES: + state_item += 1 + word = data[index_value:state_item].decode("latin-1") + if (0x30 <= data[index_value] <= 0x39) or data[index_value] in b"+-.": + try: + numeric_value = float(word) + except ValueError: + numeric_value = 0.0 + tokens.append(("num", numeric_value)) + else: + tokens.append(("op", word)) + index_value = state_item + + out: dict[int, str] = {} + + def codepoint_to_string(numeric_value: float) -> str: + # ToUnicode parsing numeric entry: code-point conversion(token) -- its + # RangeError (non-integer / out of range) kills the whole map, so + # chr's ValueError propagate. + codepoint = int(numeric_value) + if codepoint != numeric_value: + raise ValueError("code-point conversion non-integer") + return chr(codepoint) + + def map_range_units(range_start: int, range_end: int, units: list[int]) -> None: + # text extraction CMap.bf-range mapping : ``last byte`` is FIXED to + # the ORIGINAL dst length-1; only THAT byte index is incremented. On + # 0xFF overflow it carries into byte last byte-1 (byte-to-character conversion ToUint16 + # == the & 0xFFFF) and sets the tail to 0x00; the next non-overflow + # step is substring(0,last byte)+chr(next), so a 1-byte dst collapses + # back to ONE byte. A 1-byte 0xFF overflow gives "\x00\x00" + # Empty destinations yield "" for the first code and "\x00" for each + # subsequent code after carry. + last_byte = len(units) - 1 + for code in range(range_start, range_end + 1): + out[code] = _utf16be_units_to_str(units) + if last_byte < 0: + units = [0x00] + continue + cur = units[last_byte] if last_byte < len(units) else 0 + nxt = cur + 1 + if nxt > 0xFF: + if last_byte - 1 >= 0: + units = (units[:last_byte - 1] + + [(units[last_byte - 1] + 1) & 0xFFFF, 0x00]) + else: + units = [0x00, 0x00] + else: + units = units[:last_byte] + [nxt] + + key_value = 0 + while key_value < len(tokens): + kind, val = tokens[key_value] + if kind == "op" and val == "beginbfchar": + key_value += 1 + while key_value + 1 < len(tokens) and tokens[key_value][0] == "hex": + src = _cmap_str_to_int(tokens[key_value][1]) + if tokens[key_value + 1][0] != "hex": + # the heading heuristics string-operand check throws -> CMap parsing catch drops the + # rest of the block, map survives. + key_value += 2 + break + out[src] = _utf16be_units_to_str(list(tokens[key_value + 1][1])) + key_value += 2 + elif kind == "op" and val == "beginbfrange": + key_value += 1 + while (key_value + 1 < len(tokens) and tokens[key_value][0] == "hex" + and tokens[key_value + 1][0] == "hex"): + src_start = _cmap_str_to_int(tokens[key_value][1]) + src_end = _cmap_str_to_int(tokens[key_value + 1][1]) + key_value += 2 + if src_end - src_start > 0xFFFFFF: + # content stream tokenizer range-limit throw, contained by CMap parsing's + # catch: the oversized entry is dropped, the map survives. + if key_value < len(tokens) and tokens[key_value] == ("delim", "["): + while key_value < len(tokens) and tokens[key_value] != ("delim", "]"): + key_value += 1 + key_value += 1 + elif key_value < len(tokens) and tokens[key_value][0] in ("hex", "num"): + key_value += 1 + continue + if key_value < len(tokens) and tokens[key_value] == ("delim", "["): + key_value += 1 + code = src_start + while key_value < len(tokens) and tokens[key_value][0] in ("hex", "num"): + if code <= src_end: + dst_token = tokens[key_value] + out[code] = (_utf16be_units_to_str(list(dst_token[1])) + if dst_token[0] == "hex" else codepoint_to_string(dst_token[1])) + code += 1 + key_value += 1 + if key_value < len(tokens) and tokens[key_value] == ("delim", "]"): + key_value += 1 + elif key_value < len(tokens) and tokens[key_value][0] == "hex": + units = list(tokens[key_value][1]) + key_value += 1 + map_range_units(src_start, src_end, units) + elif key_value < len(tokens) and tokens[key_value][0] == "num": + # Integer destinations are one UTF-16 unit, then the normal + # increment walk applies. + units = [int(tokens[key_value][1]) & 0xFFFF] + key_value += 1 + map_range_units(src_start, src_end, units) + else: + break # parse error -> contained: drop the block + elif kind == "op" and val == "begincidchar": + key_value += 1 + while (key_value + 1 < len(tokens) and tokens[key_value][0] == "hex" + and tokens[key_value + 1][0] == "num"): + out[_cmap_str_to_int(tokens[key_value][1])] = codepoint_to_string(tokens[key_value + 1][1]) + key_value += 2 + elif kind == "op" and val == "begincidrange": + key_value += 1 + while (key_value + 2 < len(tokens) and tokens[key_value][0] == "hex" + and tokens[key_value + 1][0] == "hex" and tokens[key_value + 2][0] == "num"): + src_start = _cmap_str_to_int(tokens[key_value][1]) + src_end = _cmap_str_to_int(tokens[key_value + 1][1]) + start = tokens[key_value + 2][1] + key_value += 3 + if src_end - src_start > 0xFFFFFF: + continue # range-limit, contained + for code in range(src_start, src_end + 1): + out[code] = codepoint_to_string(start + (code - src_start)) + else: + key_value += 1 + return out diff --git a/pageindex/flash/parser_pdfium_charlevel/code_walk.py b/pageindex/flash/parser_pdfium_charlevel/code_walk.py new file mode 100644 index 000000000..09fd5b684 --- /dev/null +++ b/pageindex/flash/parser_pdfium_charlevel/code_walk.py @@ -0,0 +1,249 @@ +"""Resource-dictionary xref walking and per-page show-code enumeration.""" + +from __future__ import annotations + +import re +import unicodedata +from PyPDF2.generic import ( + IndirectObject as PdfIndirectRef, NameObject as PdfName, NumberObject as PdfNumber, + FloatObject as PdfFloat, BooleanObject as PdfBoolean, + DictionaryObject as PdfDictionary, ArrayObject as PdfArray, +) + +from .pdf_objects import _decode_pdf_name +from .text_normalize import ( + _normalize_unicodes, + _WHITESPACE_CODEPOINTS, + _is_whitespace, +) +from .content_stream import _tokenize_show_operators + + +def _resource_dict_xrefs(pdf_doc, owner_xref: int, sub: str) -> dict[bytes, int]: + """{canonical resname bytes: xref} for /Redefinitions/ of a page or Form XObject dict, following indirection; for pages, /Redefinitions may be inherited through the /Parent chain.""" + val = ("null", "null") + xref_cursor = owner_xref + for _ in range(32): # /Parent chain (pages); XObjects never recurse here + val = pdf_doc.xref_get_key(xref_cursor, f"Resources/{sub}") + if val[0] != "null": + break + if pdf_doc.xref_get_key(xref_cursor, "Resources")[0] != "null": + break # Redefinitions exists but lacks + parent_key_type, parent_xref_value = pdf_doc.xref_get_key(xref_cursor, "Parent") + if parent_key_type != "xref": + break + xref_cursor = int(parent_xref_value.split()[0]) + if val[0] == "xref": + body = pdf_doc.xref_object(int(val[1].split()[0]), compressed=True) + elif val[0] == "dict": + body = val[1] + else: + return {} + out: dict[bytes, int] = {} + for measure_item in re.finditer(r"/([^\s/\[\]<>()]+)\s+(\d+)\s+\d+\s+R", body): + out[_decode_pdf_name(measure_item.group(1).encode("latin-1"))] = int(measure_item.group(2)) + # DIRECT (inline) sub-dict entries carry no `N G R` for the regex; span merger + # reference resolution resolves them all the same, so register each as a virtual + # pseudo-xref and the normal integer-keyed pipeline address it. + try: + node = pdf_doc._resolve_object(xref_cursor) + for part in ("Resources", sub): + if isinstance(node, PdfIndirectRef): + node = node.get_object() + node = node["/" + part] if (node is not None and "/" + part in node) else None + if node is not None: + if isinstance(node, PdfIndirectRef): + node = node.get_object() + for key_value in node.keys(): + raw = node.raw_get(key_value) + if isinstance(raw, PdfIndirectRef): + continue # indirect: the regex pass covered it + if not hasattr(raw, "raw_get"): + continue # not a dict (malformed entry) + name = _decode_pdf_name(key_value.lstrip("/").encode("latin-1")) + if name not in out: + out[name] = pdf_doc.register_virtual(raw) + except Exception: + pass + return out + + +def _page_show_codes( + pdf_doc, page_idx: int, +) -> list[tuple[int | None, tuple[int, ...]]] | None: + """Every show op the page paints, in paint order, as ``(font_xref | None, charcode units)-- including text inside Form XObjects, spliced at their ``Do`` position with the XObject's own font redefinitions (span merger text-content extraction recurses the same way; PDFium's textpage flattens them inline). None when the page can't be read.""" + page_xref = pdf_doc.page_xref(page_idx) + + def walk(stream: bytes, fonts_res: dict[bytes, int], + xobjs_res: dict[bytes, int], cur_font: int | None, + visited: frozenset, depth: int, + out: list[tuple[int | None, tuple[int, ...]]]) -> None: + if depth > 8: + return + flush_ids, fonts, show_text_units, horizontal_scales, xobject_paints = _tokenize_show_operators(stream) + dict_index = 0 + for key_value in range(len(show_text_units) + 1): + while dict_index < len(xobject_paints) and xobject_paints[dict_index][0] == key_value: + paint_position, xname, font_at_do = xobject_paints[dict_index] + dict_index += 1 + xobject_ref = xobjs_res.get(xname) # lexer names arrive #XX-parsed + if xobject_ref is None or xobject_ref in visited: + continue + state_values, string_value = pdf_doc.xref_get_key(xobject_ref, "Subtype") + if state_values != "name" or string_value.lstrip("/") != "Form": + continue + sub_fonts = _resource_dict_xrefs(pdf_doc, xobject_ref, "Font") or fonts_res + sub_xobjs = _resource_dict_xrefs(pdf_doc, xobject_ref, "XObject") or xobjs_res + inherited = (fonts_res.get(font_at_do) + if font_at_do is not None else None) + try: + sub_stream = pdf_doc.xref_stream(xobject_ref) + except Exception: + # span merger: "XObject should be a stream" -> recovery mode skips + # THIS Do and keeps walking the page (a direct dict posing + # as /Form has no stream; must not kill the whole page). + continue + walk(sub_stream, sub_fonts, sub_xobjs, + inherited, visited | {xobject_ref}, depth + 1, out) + if key_value < len(show_text_units): + resource_font_name = fonts[key_value] + resource_font_index = (fonts_res.get(resource_font_name) + if resource_font_name is not None else cur_font) + out.append((resource_font_index, show_text_units[key_value])) + + try: + out: list[tuple[int | None, tuple[int, ...]]] = [] + walk( + pdf_doc[page_idx].read_contents(), + _resource_dict_xrefs(pdf_doc, page_xref, "Font"), + _resource_dict_xrefs(pdf_doc, page_xref, "XObject"), + None, frozenset(), 0, out, + ) + return out + except Exception: + return None + + +def _char_category(text: str) -> tuple[bool, bool, bool]: + """text extraction glyph Unicode category classification over a (possibly multi-char) glyph Unicode string: first match of /^(\\s)|(\\p{Mn})|(\\p{Cf})$/u decides (isWhitespace, zero-width diacritic classification, invisible format-mark classification).""" + for pos, char in enumerate(text): + codepoint = ord(char) + if pos == 0 and codepoint in _WHITESPACE_CODEPOINTS: + return True, False, False + cat = unicodedata.category(char) + if cat == "Mn": + return False, True, False + if cat == "Cf" and pos == len(text) - 1: + return False, False, True + return False, False, False + + +def _walk_codes( + chars: list[tuple[int, str]], + targets: list[str], + allow_skips: bool = False, +) -> tuple[list[tuple[int, str]], list[int], list[tuple[int, int]], + list[tuple[int, int]]] | None: + """Walk one run of font-resolved per-code targets against the PDFium chars emitted for the same run; return (patches, drops, consumed, skips) or None on desync. ``consumed`` maps each consumed char's textpage index to the target index that consumed it, which lets the group re-walk repair char-to-object attribution. ``skips`` records each skipped target as (target index, char position it belongs before) for glyph re-synthesis. PDFium's emission per code is unknowable a priori: it may match the font target, fall back to the raw code, or expand a glyph into several chars. Consumption is resolved per code by candidate match: the font target, then its normalized expansions (fixed unicode substitution table, NFKC, NFKD, NFD). On an expansion match where the final span text still converges, the chars are left alone; otherwise the first char is patched to the target unicode and the rest of the run is dropped so the single glyph still carries the advance. The run is valid only if both streams end in sync. """ + text = "".join(target_char for _, target_char in chars) + line_value = len(text) + pos = 0 + patches: list[tuple[int, str]] = [] + drops: list[int] = [] + consumed: list[tuple[int, int]] = [] # (char textpage index, target index) + skips: list[tuple[int, int]] = [] # (target index, char position) + skip_until = -1 + shift_run = 0 + for index_value, token_value in enumerate(targets): + if index_value <= skip_until: + continue # part of an anchored skip run recorded below + if pos >= line_value: + if allow_skips: + # Chars exhausted with targets left: the walk arrived here in + # sync, so every remaining target is a glyph PDFium never + # emitted (the both-exhaust gate in reverse). + skips.append((index_value, pos)) + continue + return None # codes left over: desync + target_len = len(token_value) + if text[pos:pos + target_len] == token_value: + consumed.extend((chars[query_value][0], index_value) for query_value in range(pos, pos + target_len)) + pos += target_len + shift_run = 0 + continue + matched = False + for normalized_text in (_normalize_unicodes(token_value), + unicodedata.normalize("NFKC", token_value), + unicodedata.normalize("NFKD", token_value), + unicodedata.normalize("NFD", token_value)): + if normalized_text != token_value and text[pos:pos + len(normalized_text)] == normalized_text: + if _normalize_unicodes(token_value) != normalized_text: + patches.append((chars[pos][0], token_value)) + drops.extend(chars[query_value][0] for query_value in range(pos + 1, pos + len(normalized_text))) + consumed.extend((chars[query_value][0], index_value) for query_value in range(pos, pos + len(normalized_text))) + pos += len(normalized_text) + matched = True + break + if matched: + shift_run = 0 + continue + # Anchored drop-skip (LAST-RESORT mode only: the window re-walk has + # already ruled out the stolen-edge-glyph hypothesis): when PDFium + # genuinely never emitted the glyph (font-layer drop -- dense math-heavy page's + # 4 α, math-heavy page's scanned-page '~', both absent from the textpage AND + # FPDFTextObj_GetText), the target has no char anywhere. Skip it + # WITHOUT consuming, but only when the next two unskipped targets + # literally anchor on the upcoming chars, so a mis-decode (which + # needs the 1-char patch below instead) can't be eaten as a skip. + # Drops can be CONSECUTIVE (OCR pages drop runs of glyphs), so scan + # forward for the smallest run i..i+m-1 whose following pair + # anchors; a wrong run leaves chars unconsumed and the exhaust gate + # below still rolls everything back. + if allow_skips: + skip_run_length = 0 + for skip_len in range(1, len(targets) - index_value + 1): + anchor = targets[index_value + skip_len:index_value + skip_len + 2] + if not anchor or not all(len(primary_item) == 1 for primary_item in anchor): + break + str_value = "".join(anchor) + if text[pos:pos + len(str_value)] == str_value: + skip_run_length = skip_len + break + if skip_run_length: + skips.extend((query_value, pos) for query_value in range(index_value, index_value + skip_run_length)) + skip_until = index_value + skip_run_length - 1 + shift_run = 0 + continue + # PDFium's textpage COLLAPSES space runs: a whitespace target facing + # a non-whitespace char means the space's char simply does not exist + # in the textpage (it can never be a re-decode of the current char). + # Desync rather than mis-patch the neighbouring glyph into a space; + # table rows can contain real star glyphs adjacent to synthetic spaces. + if (all(_is_whitespace(ord(unit_char)) for unit_char in token_value) + and not _is_whitespace(ord(text[pos]))): + return None + # Off-by-one guard for the 1-char assumption below: when an edge + # glyph was mis-attributed to a neighbouring object, every pair + # mismatches with the streams shifted by one, and a ligature + # expansion elsewhere can re-balance the counts so the exhaust gate + # alone would COMMIT the shifted alignment and attach punctuation to + # the wrong run. The shift has a literal signature -- + # the NEXT target equals the current char(s), or the current target + # equals the NEXT char(s) -- which legitimate decode mismatches + # (text extraction symbol vs PDFium control char) never produce. Two + # consecutive hits = systematic shift -> desync, letting the + # adjacent-run group re-walk re-align both objects cleanly. + nxt = targets[index_value + 1] if index_value + 1 < len(targets) else None + if ((nxt is not None and text[pos:pos + len(nxt)] == nxt) + or text[pos + 1:pos + 1 + target_len] == token_value): + shift_run += 1 + if shift_run >= 2: + return None + else: + shift_run = 0 + patches.append((chars[pos][0], token_value)) + consumed.append((chars[pos][0], index_value)) + pos += 1 + if pos != line_value: + return None # chars left over: desync + return patches, drops, consumed, skips diff --git a/pageindex/flash/parser_pdfium_charlevel/content_stream.py b/pageindex/flash/parser_pdfium_charlevel/content_stream.py new file mode 100644 index 000000000..809a78c4e --- /dev/null +++ b/pageindex/flash/parser_pdfium_charlevel/content_stream.py @@ -0,0 +1,424 @@ +"""Content-stream show-operator tokenization and per-page operator tagging.""" + +from __future__ import annotations + +from .pdf_objects import ( + _PDF_WHITESPACE_BYTES, + _PDF_DELIMITER_BYTES, + _PDF_STRING_ESCAPE_BYTES, + _decode_pdf_name, +) + + +# Text items start at font/size changes, positional line breaks or gaps, and +# content-stream flush operators (q/Q, Do, gs-/Font, marked content). PDFium's +# flattened FPDF_PAGEOBJ_TEXT objects can be one-per-glyph for per-glyph Tj +# streams, so this merger keeps a strict per-object split unless a later +# whitespace-aware rule proves a prose continuation. +# +# q/Q flush grouping is intentionally disabled. It requires fragile ordinal +# alignment between flattened PDFium objects and content-stream show operators, +# while the merger only needs the content stream for per-show-op font names +# (vertical-font flags) and Unicode-map reconstruction. setFont/gs-font are not +# flush scopes here; font_key/fs changes carry the style-boundary split. +_FLUSH_OPS = frozenset({b"q", b"Q", b"Do", b"BDC", b"BMC", b"EMC"}) +_SHOW_OPS = frozenset({b"Tj", b"TJ", b"'", b'"'}) +# content stream tokenizer content operator table: {op: (operand count, variable operand count)}. +# Commands NOT in this table are span merger "Unknown command" -- warned and skipped +# with the accumulated args PRESERVED (not cleared). +# content stream tokenizer operator table's null-value entries: pure lexer aids so object parser's +# longest-known-command walk can pass through prefixes of longer commands +# (B -> BM -> BMC, f -> false, n -> null). Not operators. +_OP_LEX_PREFIX = frozenset({ + b"BM", b"BD", b"true", b"fa", b"fal", b"fals", b"false", + b"nu", b"nul", b"null", +}) +_OP_OPERAND_COUNTS: dict[bytes, tuple[int, bool]] = { + b"w": (1, False), b"J": (1, False), b"j": (1, False), b"M": (1, False), + b"d": (2, False), b"ri": (1, False), b"i": (1, False), b"gs": (1, False), + b"q": (0, False), b"Q": (0, False), b"cm": (6, False), b"m": (2, False), + b"l": (2, False), b"c": (6, False), b"v": (4, False), b"y": (4, False), + b"h": (0, False), b"re": (4, False), b"S": (0, False), b"s": (0, False), + b"f": (0, False), b"F": (0, False), b"f*": (0, False), b"B": (0, False), + b"B*": (0, False), b"b": (0, False), b"b*": (0, False), b"n": (0, False), + b"W": (0, False), b"W*": (0, False), b"BT": (0, False), b"ET": (0, False), + b"Tc": (1, False), b"Tw": (1, False), b"Tz": (1, False), b"TL": (1, False), + b"Tf": (2, False), b"Tr": (1, False), b"Ts": (1, False), b"Td": (2, False), + b"TD": (2, False), b"Tm": (6, False), b"T*": (0, False), b"Tj": (1, False), + b"TJ": (1, False), b"'": (1, False), b'"': (3, False), b"d0": (2, False), + b"d1": (6, False), b"CS": (1, False), b"cs": (1, False), b"SC": (4, True), + b"SCN": (33, True), b"sc": (4, True), b"scn": (33, True), b"G": (1, False), + b"g": (1, False), b"RG": (3, False), b"rg": (3, False), b"K": (4, False), + b"k": (4, False), b"sh": (1, False), b"BI": (0, False), b"ID": (0, False), + b"EI": (1, False), b"Do": (1, False), b"MP": (1, False), b"DP": (2, False), + b"BMC": (1, False), b"BDC": (2, False), b"EMC": (0, False), + b"BX": (0, False), b"EX": (0, False), +} + + +def _tokenize_show_operators( + content_bytes: bytes, +) -> tuple[list[int], list[bytes | None], list[tuple[int, ...]], list[float], + list[tuple[int, bytes, bytes | None]]]: + """Tokenize a PDF page content stream. For each text-showing operator, records the active flush scope, font redefinition name, raw charcode units, horizontal scaling, and Form XObject paint position. The tokenizer is deliberately tolerant of malformed operators: it skips bad or short operands, preserves unknown-command operands, and emits an empty string for a show operator with the wrong string operand type.""" + flush_ids: list[int] = [] + fonts: list[bytes | None] = [] + show_text_units: list[tuple[int, ...]] = [] + xobject_paints: list[tuple[int, bytes, bytes | None]] = [] + horizontal_scales: list[float] = [] + flush_id = 0 + cur_font: bytes | None = None + font_stack: list[bytes | None] = [] + cur_tz = 1.0 + tz_stack: list[float] = [] + opnds: list[tuple[str, object]] = [] + frames: list[tuple[str, list]] = [] # open [ / << collectors + non_processed: list[tuple[str, object]] = [] + bi_mark: int | None = None + + def push(kind: str, val: object) -> None: + (frames[-1][1] if frames else opnds).append((kind, val)) + + index_value = 0 + count_item = len(content_bytes) + while index_value < count_item: + byte_value = content_bytes[index_value] + if byte_value in _PDF_WHITESPACE_BYTES: + index_value += 1 + elif byte_value == 0x25: # % comment -> end of line + while index_value < count_item and content_bytes[index_value] not in b"\r\n": + index_value += 1 + elif byte_value == 0x28: # ( literal string: decode per PDF 7.3.4.2 + depth = 0 + out: list[int] = [] + while index_value < count_item: + literal_byte = content_bytes[index_value] + if literal_byte == 0x5c: # backslash escape + if index_value + 1 >= count_item: + index_value += 1 + break + escape_byte = content_bytes[index_value + 1] + if escape_byte in _PDF_STRING_ESCAPE_BYTES: + out.append(_PDF_STRING_ESCAPE_BYTES[escape_byte]) + index_value += 2 + elif 0x30 <= escape_byte <= 0x37: # \ddd octal, 1-3 digits + token_end = index_value + 1 + val = 0 + while token_end < count_item and token_end - index_value <= 3 and 0x30 <= content_bytes[token_end] <= 0x37: + val = (val << 3) | (content_bytes[token_end] - 0x30) + token_end += 1 + # text extraction literal-string lexer pushes byte-to-character conversion with NO + # byte mask: \400..\777 stay 256..511 (the PDF-spec + # high-order-overflow mask is deliberately absent). + out.append(val) + index_value = token_end + elif escape_byte in (0x0D, 0x0A): # \ line continuation + index_value += 2 + if escape_byte == 0x0D and index_value < count_item and content_bytes[index_value] == 0x0A: + index_value += 1 + else: # \x -> x + out.append(escape_byte) + index_value += 2 + continue + # NOTE: bare CR/LF inside a literal string fall through to the + # raw push below: literal strings keep bare CR/LF as-is here + # rather than applying PDF-spec "treat as 0x0A" normalization + # is deliberately absent there; no CRLF collapsing either). + if literal_byte == 0x28: + if depth: + out.append(literal_byte) + depth += 1 + elif literal_byte == 0x29: + depth -= 1 + if depth == 0: + index_value += 1 + break + out.append(literal_byte) + else: + out.append(literal_byte) + index_value += 1 + push("str", tuple(out)) + elif byte_value == 0x3c: # < : << dict-open, else + if index_value + 1 < count_item and content_bytes[index_value + 1] == 0x3c: + frames.append(("dict", [])) + index_value += 2 + else: + index_value += 1 + nib: list[int] = [] + while index_value < count_item and content_bytes[index_value] != 0x3e: + hex_byte = content_bytes[index_value] + if 0x30 <= hex_byte <= 0x39: + nib.append(hex_byte - 0x30) + elif 0x41 <= hex_byte <= 0x46: + nib.append(hex_byte - 0x37) + elif 0x61 <= hex_byte <= 0x66: + nib.append(hex_byte - 0x57) + index_value += 1 + index_value += 1 + if len(nib) % 2: + nib.pop() # drop a lone trailing hex digit + push("str", tuple( + (nib[key_value] << 4) | nib[key_value + 1] for key_value in range(0, len(nib), 2) + )) + elif byte_value == 0x3e: # >> dict-close (or stray >) + if index_value + 1 < count_item and content_bytes[index_value + 1] == 0x3e: + index_value += 2 + if frames and frames[-1][0] == "dict": + frames.pop() + push("dict", None) + # stray >> : text extraction command token -> unknown command -> tally preserved + else: + index_value += 1 + elif byte_value == 0x5b: # [ -- one array operand (text extraction parser builds an array) + frames.append(("arr", [])) + index_value += 1 + elif byte_value == 0x5d: # ] + index_value += 1 + if frames and frames[-1][0] == "arr": + items = frames.pop()[1] + # TJ semantics: only direct string elements show; numbers are + # kern adjustments and nested non-strings are ignored. + push("arr", tuple(codepoint for kerning_delta, vertical_value in items if kerning_delta == "str" + for codepoint in vertical_value)) # type: ignore[union-attr] + # stray ] : text extraction command token(']') -> unknown command -> tally preserved + elif byte_value in b"{}": + index_value += 1 # text extraction command token -> not in operator table -> "Unknown command", preserved + elif byte_value == 0x2f: # /name operand + index_value += 1 + token_end = index_value + while token_end < count_item and content_bytes[token_end] not in _PDF_WHITESPACE_BYTES and content_bytes[token_end] not in _PDF_DELIMITER_BYTES: + token_end += 1 + # Decode #XX escapes while lexing names so consumers receive the + # canonical name and do not re-decode downstream. + push("name", _decode_pdf_name(content_bytes[index_value:token_end])) + index_value = token_end + else: # number, keyword operand, or operator + first_char = content_bytes[index_value] + if (0x30 <= first_char <= 0x39) or first_char in b"+-.": + # Number token: consume the whole run to whitespace/delimiter. + # Malformed numeric runs are zeroed by the downstream parser. + token_end = index_value + while token_end < count_item and content_bytes[token_end] not in _PDF_WHITESPACE_BYTES and content_bytes[token_end] not in _PDF_DELIMITER_BYTES: + token_end += 1 + else: + # text extraction command lexing: once the accumulated run IS a + # known command, stop extending as soon as the next char + # would break that -- 'q1' lexes as command token 'q' + number 1 + # (real-world PDFs; text extraction built known commands for them). + token_end = index_value + known = False + while token_end < count_item and content_bytes[token_end] not in _PDF_WHITESPACE_BYTES and content_bytes[token_end] not in _PDF_DELIMITER_BYTES: + cand = content_bytes[index_value:token_end + 1] + if (known and cand not in _OP_OPERAND_COUNTS + and cand not in _OP_LEX_PREFIX): + break + token_end += 1 + known = (content_bytes[index_value:token_end] in _OP_OPERAND_COUNTS + or content_bytes[index_value:token_end] in _OP_LEX_PREFIX) + operator_token = content_bytes[index_value:token_end] + index_value = token_end + if not operator_token: + index_value += 1 + continue + if (0x30 <= first_char <= 0x39) or first_char in b"+-.": + # text extraction number lexer accepts only digit/sign/dot/exponent + # runs; Python float would also take "-inf"/"nan" tokens, + # which must not poison the operand (or the Tz state). + try: + num_val = float(operator_token) + except ValueError: + num_val = 0.0 + if num_val != num_val or num_val in (float("inf"), -float("inf")): + num_val = 0.0 + push("num", num_val) + continue + if operator_token in (b"true", b"false"): + push("other", None) # text extraction booleans -> operands + continue + if operator_token == b"null": + continue # content operator evaluator: `if (obj != null) args append` + + if frames: + # text extraction builds arrays/dicts by recursive object parser: a command + # token inside an open [ / << becomes an ELEMENT, never an op. + frames[-1][1].append(("other", None)) + continue + if operator_token == b"BI": + # text extraction object parser intercepts BI (inline-image parser): the + # image never reaches the operator table protocol; it becomes ONE arg. + bi_mark = len(opnds) + continue + spec = _OP_OPERAND_COUNTS.get(operator_token) + if spec is None: + continue # span merger: warn "Unknown command", tally PRESERVED + if operator_token == b"ID": # inline image data (text extraction inline-image parser) + # Filter-specific ender first (content stream tokenizer dispatch): DCT scans + # for the FFD9 EOI, ASCII85 for '~>', ASCIIHex for '>'; then + # the 'EI' marker whose FOLLOWING byte is SPACE/LF/CR + # (inline-image end search -- there is NO whitespace + # requirement BEFORE the marker: inline-image data can touch it). + # span merger extra 10-byte-lookahead / lookahead false-EI checks + # are not reproduced (light version). + filt = b"" + if bi_mark is not None: + for kerning_delta, vertical_value in opnds[bi_mark:]: + if kerning_delta == "name" and vertical_value in ( + b"DCTDecode", b"DCT", b"ASCII85Decode", + b"A85", b"ASCIIHexDecode", b"AHx"): + filt = vertical_value + break + key_value = index_value + 1 + if filt in (b"DCTDecode", b"DCT"): + measure_item = content_bytes.find(b"\xff\xd9", key_value) + if measure_item >= 0: + key_value = measure_item + 2 + elif filt in (b"ASCII85Decode", b"A85"): + measure_item = content_bytes.find(b"~>", key_value) + if measure_item >= 0: + key_value = measure_item + 2 + elif filt in (b"ASCIIHexDecode", b"AHx"): + measure_item = content_bytes.find(b">", key_value) + if measure_item >= 0: + key_value = measure_item + 1 + while key_value < count_item - 1: + if (content_bytes[key_value] == 0x45 and content_bytes[key_value + 1] == 0x49 + and (key_value + 2 >= count_item or content_bytes[key_value + 2] in b" \n\r")): + index_value = key_value + 2 + break + key_value += 1 + else: + index_value = count_item # EOF recovery (text extraction inline-image end recovery) + if bi_mark is not None: + del opnds[bi_mark:] # the BI..ID dict guts + bi_mark = None + opnds.append(("other", None)) # the InlineImage operand + # text extraction then executes a synthetic command token EI (operand count 1): + # pre-BI dangles shift into deferred-operand, the image + # operand is consumed, args end empty. + while len(opnds) > 1: + non_processed.append(opnds.pop(0)) + opnds.clear() + else: + # Stray ID without BI: text extraction dispatches it via operator table + # (operand count 0), shifting every pending arg into the + # deferred-operand stack before the no-op executes. + non_processed.extend(opnds) + opnds.clear() + continue + need, variable = spec + if not variable and len(opnds) != need: + while len(opnds) > need: + non_processed.append(opnds.pop(0)) + while len(opnds) < need and non_processed: + opnds.insert(0, non_processed.pop()) + if len(opnds) < need: + # Detail: "Skipping command ...: expected N args" + args + + # cleared; the op has NO side effect (no flush, no Tf). + opnds.clear() + continue + if operator_token in _FLUSH_OPS: + flush_id += 1 + if operator_token == b"q": + font_stack.append(cur_font) + tz_stack.append(cur_tz) + elif operator_token == b"Q": + if font_stack: + cur_font = font_stack.pop() + if tz_stack: + cur_tz = tz_stack.pop() + elif operator_token == b"Do" and opnds[0][0] == "name": + xobject_paints.append((len(flush_ids), opnds[0][1], cur_font)) # type: ignore[arg-type] + elif operator_token == b"Tf": + if opnds[0][0] == "name": + cur_font = opnds[0][1] # type: ignore[assignment] + else: + # text extraction REPLACES the font either way: a non-name slot + # loads undefined -> fallback/fallback font, so the previous + # font is gone. None = "no usable resname" here. + cur_font = None + elif operator_token == b"Tz": + # content stream tokenizer horizontal-scale operator: the text state's horizontal scale = args[0]/100 + # (any type, ToNumber-coerced). We track only numeric operands: + # the divisor must account for what PDFIUM folded into the object + # matrix, and PDFium's own parser rejects non-numeric Tz -- + # following span merger coercion here would break consistency with the horizontal font scale. + if opnds[0][0] == "num": + cur_tz = opnds[0][1] / 100.0 # type: ignore[operator] + elif operator_token in _SHOW_OPS: + if operator_token == b"TJ": + primary_item = opnds[0] + # the spaced-text show operator iterates elements by .length/.at, which a + # plain STRING also satisfies -- its chars all show. + units = primary_item[1] if primary_item[0] in ("arr", "str") else () + elif operator_token == b'"': + primary_item = opnds[2] + units = primary_item[1] if primary_item[0] == "str" else () + else: # Tj, ' + primary_item = opnds[0] + units = primary_item[1] if primary_item[0] == "str" else () + # A wrong-typed slot or an empty string yields ZERO glyphs in + # span merger (glyph conversion -> no item pushed) and no PDFium text + # object either -- emit no show entry, so both ordinal + # alignments (objects <-> show ops) stay tight. + if units: + flush_ids.append(flush_id) + fonts.append(cur_font) + horizontal_scales.append(cur_tz) + show_text_units.append(units) # type: ignore[arg-type] + opnds.clear() # executed op consumes its args (caller resets) + return flush_ids, fonts, show_text_units, horizontal_scales, xobject_paints + + +def _assign_vertical_tags( + objects: list[dict], + show_fonts: list[bytes | None] | None = None, + vertical_resnames: set[bytes] | None = None, +) -> None: + """Tag each text object (paint order) with the vertical-font flag from its matching show-text operator. Ordinal alignment is valid when object and show-op counts agree, such as ligature-free pages and per-glyph CJK Tj streams. On a count mismatch the tag stays False and vertical runs fall back to per-glyph handling. Objects keep ``flush_id=None`` so the merger always splits per object.""" + if not objects or not vertical_resnames or not show_fonts: + return + if len(show_fonts) != len(objects): + return + for item_value, font_name_value in zip(objects, show_fonts): + if font_name_value is not None and font_name_value in vertical_resnames: + item_value["vertical"] = True + + +def _assign_show_tz(objects: list[dict], show_tzs: list[float]) -> None: + """Tag each text object with its show-op's text horizontal scale (Tz/100) by the same ordinal alignment as ``_assign_vertical_tags``; on a count mismatch every object keeps tz=1.0 (thresholds behave as before).""" + if not objects or not show_tzs or len(show_tzs) != len(objects): + return + for item_value, timezone_value in zip(objects, show_tzs): + item_value["tz"] = timezone_value + + +def _page_vertical_resource_names(pdf_doc, page_idx: int) -> set[bytes]: + """Font resource names (``F4`` of ``/F4 14 Tf``) on this page whose encoding is a vertical CMap: a predefined ``*-V`` name (Identity-V, UniJIS-UCS2-V, ...) or an embedded CMap stream with ``/WMode 1``. This derives the vertical-font flag used by the item merger, read from the same PyPDF2 document already opened for content streams. Returns an empty set on any failure, which leaves vertical handling disabled for that page.""" + names: set[bytes] = set() + try: + for rec in pdf_doc[page_idx].get_fonts(full=True): + xref, font_extension, font_type, _basefont, resname, enc = rec[:6] + # Predefined vertical CMaps: every shipped vertical bcmap ends in + # "-V" EXCEPT the bare Adobe-Japan1 "V" (bcmaps/V.bcmap, header + # bit 1 set -- content stream tokenizer reads verticality from that bit). + if isinstance(enc, str) and (enc == "V" or enc.endswith("-V")): + names.add(resname.encode("latin-1", "replace")) + continue + # Embedded CMap: /Encoding is an indirect stream; vertical iff its + # dict carries /WMode 1. + page, resource_names = pdf_doc.xref_get_key(xref, "Encoding") + if page == "xref": + width_type, width_value_local = pdf_doc.xref_get_key(int(resource_names.split()[0]), "WMode") + if width_type in ("int", "real"): + try: + wmode_number = float(width_value_local.split()[0]) + except ValueError: + wmode_number = float("nan") + # Only integer-valued nonzero numbers enable the vertical + # font flag, so ``/WMode 1.0`` still counts. + if wmode_number.is_integer() and wmode_number != 0: + names.add(resname.encode("latin-1", "replace")) + except Exception: + return set() + return names diff --git a/pageindex/flash/parser_pdfium_charlevel/font_unicode.py b/pageindex/flash/parser_pdfium_charlevel/font_unicode.py new file mode 100644 index 000000000..fd44fe1ec --- /dev/null +++ b/pageindex/flash/parser_pdfium_charlevel/font_unicode.py @@ -0,0 +1,388 @@ +"""Simple-font encoding resolution and per-font Unicode map construction.""" + +from __future__ import annotations + +import re + +from .glyph_tables import ( + _load_glyph_tables, + _get_unicode_for_glyph, + _from_char_code, +) +from .cmap_parse import ( + _to_number, + _parse_int, + _parse_tounicode_cmap, +) + + +_TYPE1_SPECIAL_BYTES = b"/[]{}()" +# content stream tokenizer tokenises with PDF parser whitespace = {SP, TAB, CR, LF} +# ONLY -- narrower than the content-stream/CMap lexer's whitespace-byte set (no 0x0C, no 0x00). +_TYPE1_WHITESPACE_BYTES = frozenset(b" \t\r\n") + + +def _type1_builtin_encoding(font_file: bytes): + """content stream tokenizer font-header extraction's /Encoding case, run over the cleartext segment of an embedded Type1 font file. Returns ("named", encoding-name) | ("array", {code: glyphname}) | None.""" + end = font_file.find(b"eexec") + head = font_file[: end if end >= 0 else len(font_file)] + + header_tokens: list[bytes] = [] + index_value, count_item = 0, len(head) + while index_value < count_item: + candidate_item = head[index_value] + if candidate_item in _TYPE1_WHITESPACE_BYTES: + index_value += 1 + elif candidate_item == 0x25: # % comment runs to EOL (PDF token reader's comment eater) + while index_value < count_item and head[index_value] not in b"\r\n": + index_value += 1 + elif candidate_item in _TYPE1_SPECIAL_BYTES: + header_tokens.append(head[index_value:index_value + 1]) + index_value += 1 + else: + state_item = index_value + while state_item < count_item and head[state_item] not in _TYPE1_WHITESPACE_BYTES and head[state_item] not in _TYPE1_SPECIAL_BYTES: + state_item += 1 + header_tokens.append(head[index_value:state_item]) + index_value = state_item + + def _header_token(number: int) -> bytes | None: + return header_tokens[number] if number < len(header_tokens) else None + + # font-header extraction consumes "/"+name PAIRS and keeps scanning after each + # case, so a LATER /Encoding overwrites an earlier one (last wins), and a + # "//Encoding" pair is consumed whole (its bare "Encoding" never matches). + result: tuple | None = None + page_value = 0 + while page_value < len(header_tokens): + if header_tokens[page_value] != b"/": + page_value += 1 + continue + name_tok = _header_token(page_value + 1) + page_value += 2 # the name scanner advances past the slash unconditionally after a '/' + if name_tok != b"Encoding": + continue + arg = _header_token(page_value) + if arg is None: + # Detail: encoding lookup(null) -> null assigned to built-in encoding. + + result = None + break + if not arg.isdigit(): + # named encoding: encoding lookup(name) -- null when unknown + # Overwrite any previous result; later encoding declarations win. + glyph_name, encs = _load_glyph_tables() + name = arg.decode("latin-1") + result = ("named", name) if name in encs else None + page_value += 1 + continue + # Decimal integer count is parsed through float64, then coerced to int32. + # Huge digit strings may round or overflow to Infinity before coercion. + array_size_float = float(arg) + size = 0 if array_size_float == float("inf") else ((int(array_size_float) + 2**31) % 2**32) - 2**31 + page_value += 1 # at 'array' + enc: dict[int, str] = {} + for _ in range(size): + token_value = _header_token(page_value) + while token_value is not None and token_value not in (b"dup", b"def"): + page_value += 1 + token_value = _header_token(page_value) + if token_value is None: + # Invalid headers abort the scan and keep any previous encoding. + return result + if token_value == b"def": + break + page_value += 1 # past 'dup' + # Malformed integer tokens coerce to 0 and do not abort the entry. + token_value = _header_token(page_value) + try: + value = _parse_int(token_value.decode("latin-1"), 10) if token_value is not None else 0.0 + except OverflowError: + value = float("inf") # huge digit run + if value != value or value == float("inf") or value == -float("inf"): + value = 0.0 # ToInt32(NaN / ±Infinity) = 0 + idx = ((int(value) + 2**31) % 2**32) - 2**31 + page_value += 1 + page_value += 1 # '/' slot consumed blindly + group_value = _header_token(page_value) + page_value += 1 + if group_value is not None: + enc[idx] = group_value.decode("latin-1") + page_value += 1 # 'put' slot consumed blindly + result = ("array", enc) # keep scanning: a later /Encoding wins + return result + + +def _simple_font_to_unicode( + default_enc: list[str], + base_encoding_name: str | None, + differences: dict[int, str], + force_glyphs: bool = False, +) -> dict[int, str]: + """content stream tokenizer simple-font Unicode-map construction, detailed behavior (including the byte-to-character conversion 16-bit truncation on glyphlist hits, the Gxx/g00xx/Cdd/cdd/u heuristics, the base encoding correction branch, and the forced glyph-name pass re-parse when a Cdd name turns out hexadecimal).""" + glyphs, encs = _load_glyph_tables() + encoding: dict[int, str] = {font: glyph_name_value for font, glyph_name_value in enumerate(default_enc)} + for font, glyph_name_value in differences.items(): + if glyph_name_value == ".notdef": + continue # text extraction skips .notdef (.notdef entries) + encoding[font] = glyph_name_value + + to_unicode: dict[int, str] = {} + for charcode in sorted(encoding): + glyph_name = encoding[charcode] + if glyph_name == "": + continue + codepoint = glyphs.get(glyph_name) + if codepoint is not None: + to_unicode[charcode] = _from_char_code(codepoint) + continue + code = 0 + glyph_prefix = glyph_name[0] + if glyph_prefix == "G": # Gxx + if len(glyph_name) == 3: + parsed_integer = _parse_int(glyph_name[1:], 16) + code = int(parsed_integer) if parsed_integer == parsed_integer else 0 # pi==pi: not NaN + elif glyph_prefix == "g": # g00xx + if len(glyph_name) == 5: + parsed_integer = _parse_int(glyph_name[1:], 16) + code = int(parsed_integer) if parsed_integer == parsed_integer else 0 + elif glyph_prefix in ("C", "c"): # Cdd{d} / cdd{d} + if 3 <= len(glyph_name) <= 4: + code_str = glyph_name[1:] + if force_glyphs: + parsed_integer = _parse_int(code_str, 16) + code = int(parsed_integer) if parsed_integer == parsed_integer else 0 + else: + # First try the full numeric grammar. Only when that is NaN + # and tolerant base-16 parsing succeeds do we re-parse the + # whole encoding as base-16. Non-integer numeric values pass + # through and then fail the integer gate below. + num = _to_number(code_str) + if num != num: # NaN + parsed_integer = _parse_int(code_str, 16) + if parsed_integer == parsed_integer: + return _simple_font_to_unicode( + default_enc, base_encoding_name, + differences, force_glyphs=True) + code = 0 + elif num.is_integer(): + code = int(num) + else: + code = 0 + elif glyph_prefix == "u": + unicode_unit = _get_unicode_for_glyph(glyph_name, glyphs) + if unicode_unit != -1: + code = unicode_unit + if 0 < code <= 0x10FFFF: + # Prefer the base encoding glyph when code == charcode + if base_encoding_name and code == charcode: + base = encs.get(base_encoding_name) + # the heading heuristics base encoding[charcode] for charcode > 255 is undefined + # (falsy) -- fall through instead of IndexError. + if base and 0 <= charcode < len(base) and base[charcode]: + to_unicode[charcode] = _from_char_code( + glyphs.get(base[charcode], 0)) + continue + to_unicode[charcode] = chr(code) # code-point conversion + return to_unicode + + +def _font_unicode_map(pdf_doc, xref: int) -> tuple[int, dict[int, str]] | None: + """Return the final per-charcode glyph-unicode map for one font as ``(bytes_per_code, {charcode: unicode})``. Simple fonts use 1-byte codes; Identity-H/V composite fonts use 2-byte codes with the included ToUnicode map. ``None`` means uncovered input such as non-Identity composite CMaps or unreadable dictionaries; callers then skip the page patch walk and keep PDFium's output.""" + glyphs, encs = _load_glyph_tables() + + def _xref_key(number: int, other_text: str) -> tuple[str, str]: + return pdf_doc.xref_get_key(number, other_text) + + pdf_value_type, pdf_value = _xref_key(xref, "Subtype") + subtype = pdf_value.lstrip("/") if pdf_value_type == "name" else "" + if subtype == "Type0": + pdf_value_type, pdf_value = _xref_key(xref, "Encoding") + if pdf_value_type != "name" or pdf_value.lstrip("/") not in ("Identity-H", "Identity-V"): + return None + # text extraction reads ToUnicode from the DESCENDANT dict first, then the + # Type0 dict (the composite-font prepass uses the descendant for composites). + desc_xref = 0 + delta_top, delta_value = _xref_key(xref, "DescendantFonts") + if delta_top == "xref": + delta_value = pdf_doc.xref_object(int(delta_value.split()[0]), compressed=True) + delta_top = "array" + if delta_top == "array": + delta_matrix = re.search(r"(\d+)\s+\d+\s+R", delta_value) + if delta_matrix: + desc_xref = int(delta_matrix.group(1)) + pdf_value_type, pdf_value = ("null", "null") + if desc_xref: + pdf_value_type, pdf_value = _xref_key(desc_xref, "ToUnicode") + if pdf_value_type != "xref": + pdf_value_type, pdf_value = _xref_key(xref, "ToUnicode") + if pdf_value_type != "xref": + # No ToUnicode: text extraction predefined collection Unicode-map construction maps Adobe-{GB1,CNS1,Japan1, + # Korea1} CIDSystemInfo through the shipped Adobe-XX-UCS2 bcmap + # (real unicode per cid) -- not implemented. Returning identity + # chr(cid) would actively CORRUPT PDFium's table-driven decode + # for that class, so keep the guarded None (PDFium output). + # Every other registry/ordering IS the heading heuristics identity fallback. + if desc_xref: + right_type, right_value_local = _xref_key(desc_xref, "CIDSystemInfo/Registry") + other_type, other_value_local = _xref_key(desc_xref, "CIDSystemInfo/Ordering") + reg = re.sub(r"[()\s]", "", right_value_local) if right_type != "null" else "" + ordering = re.sub(r"[()\s]", "", other_value_local) if other_type != "null" else "" + if reg == "Adobe" and ordering in ("GB1", "CNS1", "Japan1", "Korea1"): + return None + return 2, {} # identity Unicode map: unicode == chr(cid) + try: + return 2, _parse_tounicode_cmap( + pdf_doc.xref_stream(int(pdf_value.split()[0]))) + except Exception: + return 2, {} # ToUnicode parsing error path -> identity fallback + pdf_value_type, pdf_value = _xref_key(xref, "BaseFont") + base_font = pdf_value.lstrip("/") if pdf_value_type == "name" else "" + + flags = 0 + fd_xref = 0 + has_descriptor = False + pdf_value_type, pdf_value = _xref_key(xref, "FontDescriptor") + if pdf_value_type == "xref": + fd_xref = int(pdf_value.split()[0]) + has_descriptor = True + font_token, font_value = _xref_key(fd_xref, "Flags") + if font_token == "int": + flags = int(font_value) + elif pdf_value_type == "dict": + has_descriptor = True + flags_match = re.search(r"/Flags\s+([+-]?\d+)", pdf_value) + if flags_match: + flags = int(flags_match.group(1)) + if not has_descriptor and subtype != "Type3": + # font loading's simulated descriptor (span merger `if (!descriptor)`, + # non-Type3 branch): flags come from the BaseFont name with the style + # suffix stripped -- Symbol/Dingbats/ZapfDingbats get Symbolic, all + # else Nonsymbolic. (the heading heuristics also sets Serif/FixedPitch there; nothing in + # this implementation consults those bits, so they are not simulated.) A missing + # BaseFont makes the heading heuristics throw parse error -> fallback font, i.e. text extraction DROPS + # that font's text entirely; returning None keeps PDFium's decode + # instead -- the implementation's conservative boundary, not the same branch. Type3 + # takes the OTHER the heading heuristics arm: + # a barebones descriptor with NO flags and NO BaseFont requirement + # (dvips bitmap fonts have neither), so flags stay 0 there. + if not base_font: + return None + base_wo_style = re.sub(r"[,_]", "-", base_font).split("-")[0] + flags = 4 if base_wo_style in ("Symbol", "Dingbats", "ZapfDingbats") else 32 + + file_key = None + if fd_xref: + for char_code in ("FontFile", "FontFile2", "FontFile3"): + font_token, font_value = _xref_key(fd_xref, char_code) + if font_token == "xref": + file_key = (char_code, int(font_value.split()[0])) + break + + # --- encoding and Differences extraction: /Encoding -> base encodingName + differences + differences: dict[int, str] = {} + base_encoding_name: str | None = None + pdf_value_type, pdf_value = _xref_key(xref, "Encoding") + enc_obj: str | None = None + if pdf_value_type == "name": + base_encoding_name = pdf_value.lstrip("/") + elif pdf_value_type == "xref": + enc_obj = pdf_doc.xref_object(int(pdf_value.split()[0]), compressed=True) + elif pdf_value_type == "dict": + enc_obj = pdf_value + if enc_obj is not None: + flags_match = re.search(r"/BaseEncoding\s*/([^\s/\[\]<>()]+)", enc_obj) + if flags_match: + base_encoding_name = flags_match.group(1) + flags_match = re.search(r"/Differences\s*\[", enc_obj) + if flags_match: + depth = 1 + scan_index = flags_match.end() + while scan_index < len(enc_obj) and depth: + if enc_obj[scan_index] == "[": + depth += 1 + elif enc_obj[scan_index] == "]": + depth -= 1 + scan_index += 1 + idx = 0 + for token_match in re.findall(r"/([^\s/\[\]<>()]+)|(\d+)", enc_obj[flags_match.end():scan_index - 1]): + if token_match[1]: + idx = int(token_match[1]) + else: + name_value = re.sub( + r"#([0-9a-fA-F]{2})", + lambda encoding_key: chr(int(encoding_key.group(1), 16)), token_match[0]) + differences[idx] = name_value + idx += 1 + # Table 114: a named base encoding must be one of these three. + if base_encoding_name not in ("MacRomanEncoding", "MacExpertEncoding", + "WinAnsiEncoding"): + base_encoding_name = None + + if base_encoding_name: + default_name = base_encoding_name + else: + symbolic = bool(flags & 4) + nonsymbolic = bool(flags & 32) + default_name = "StandardEncoding" + if subtype == "TrueType" and not nonsymbolic: + default_name = "WinAnsiEncoding" + if symbolic: + default_name = "MacRomanEncoding" + if file_key is None: + if re.search(r"Symbol", base_font, re.IGNORECASE): + default_name = "SymbolSetEncoding" + elif re.search(r"Dingbats|Wingdings", base_font, re.IGNORECASE): + default_name = "ZapfDingbatsEncoding" + default_enc = encs[default_name] + has_encoding = bool(base_encoding_name) or bool(differences) + + included: dict[int, str] | None = None + pdf_value_type, pdf_value = _xref_key(xref, "ToUnicode") + if pdf_value_type == "xref": + try: + included = _parse_tounicode_cmap(pdf_doc.xref_stream(int(pdf_value.split()[0]))) + except Exception: + included = None # ToUnicode parsing error path: treat as absent + + # Detail: included ToUnicode-map flag = !!toUnicode and toUnicode.length > 0. An + + # empty-but-valid ToUnicode (parsed to {}) is treated as ABSENT, so fall + # through to _simple_font_to_unicode + the Type1 builtin amend below + # (Type 1 Unicode-map repair), while preserving the existing item-boundary semantics. + if included: + final = dict(included) + if has_encoding: # predefined collection Unicode-map construction -> fallback Unicode map gap fill + for font, glyph_name in _simple_font_to_unicode( + default_enc, base_encoding_name, differences).items(): + if font not in final: + final[font] = glyph_name + return 1, final + + final = _simple_font_to_unicode(default_enc, base_encoding_name, differences) + # Type 1 Unicode-map repair: amend from the embedded Type1 program's builtin + # encoding (codes not already fixed by the dict's Encoding entry). + if file_key is not None and file_key[0] == "FontFile" and subtype in ( + "Type1", "MMType1"): + try: + builtin = _type1_builtin_encoding(pdf_doc.xref_stream(file_key[1])) + except Exception: + builtin = None + if builtin is not None: + kind, payload = builtin + # `built-in encoding == properties.defaultEncoding` (same module + + # array object) -- true iff both name the same predefined encoding. + if not (kind == "named" and payload == default_name): + items: list[tuple[int, str]] = ( + list(enumerate(encs[payload])) if isinstance(payload, str) + else sorted(payload.items())) + for font, name_value in items: + if has_encoding and (base_encoding_name or font in differences): + continue + if not name_value: + continue + codepoint = _get_unicode_for_glyph(name_value, glyphs) + if codepoint != -1: + final[font] = _from_char_code(codepoint) # amend overwrites + return 1, final diff --git a/pageindex/flash/parser_pdfium_charlevel/geometry.py b/pageindex/flash/parser_pdfium_charlevel/geometry.py new file mode 100644 index 000000000..ada1bea77 --- /dev/null +++ b/pageindex/flash/parser_pdfium_charlevel/geometry.py @@ -0,0 +1,222 @@ +"""Transform matrices, text-object collection, and char-to-object mapping.""" + +from __future__ import annotations + +import ctypes +import math +import pypdfium2.raw as pdfium_c + + +def _obj_rotation(value: float, other_item: float, candidate_item: float, reference_item: float) -> int: + """Classify a text-object matrix as upright, cardinal rotation, or oblique. Near-cardinal matrices snap to the cardinal bucket; genuinely oblique matrices use the baseline remerge path.""" + x_scale = math.hypot(value, other_item) + y_scale = math.hypot(candidate_item, reference_item) + if x_scale < 1e-9 or y_scale < 1e-9: + return 0 + eps = 1e-3 + if abs(other_item) < eps * x_scale and abs(candidate_item) < eps * y_scale: + return 0 if value >= 0 else 180 + if abs(value) < eps * x_scale and abs(reference_item) < eps * y_scale: + return 90 if other_item > 0 else 270 + return -1 + + +def _xf_point(items: tuple, other_item: float, candidate_item: float) -> tuple[float, float]: + """Apply an (a,b,c,d,e,f) PDF matrix to a point (row-vector convention).""" + return (items[0] * other_item + items[2] * candidate_item + items[4], items[1] * other_item + items[3] * candidate_item + items[5]) + + +def _compose_mtx(first_matrix: tuple, second_matrix: tuple) -> tuple: + """Matrix product applying ``m1`` first, then ``m2``.""" + return ( + first_matrix[0] * second_matrix[0] + first_matrix[1] * second_matrix[2], + first_matrix[0] * second_matrix[1] + first_matrix[1] * second_matrix[3], + first_matrix[2] * second_matrix[0] + first_matrix[3] * second_matrix[2], + first_matrix[2] * second_matrix[1] + first_matrix[3] * second_matrix[3], + first_matrix[4] * second_matrix[0] + first_matrix[5] * second_matrix[2] + second_matrix[4], + first_matrix[4] * second_matrix[1] + first_matrix[5] * second_matrix[3] + second_matrix[5], + ) + + +_IDENT_MTX = (1.0, 0.0, 0.0, 1.0, 0.0, 0.0) + + +def _collect_text_objs(page, text_page) -> list[dict]: + """Per-page list of (font_handle, fs_raw, matrix_scale_*, bbox, ...) for each text object. Used for bbox-containment lookup. Walks Form XObjects manually in stream order, composing each ancestor form's matrix. Without the composition a scaled or shifted chart's text objects land at the wrong page position and every chart glyph fails the bbox-containment lookup.""" + objects: list[dict] = [] + sz_field = ctypes.c_float(0) + matrix = pdfium_c.FS_MATRIX() + font_name_buffer = (ctypes.c_char * 256)() + bounds_left = ctypes.c_float(0) + value = ctypes.c_float(0) + bounds_right = ctypes.c_float(0) + bounds_top = ctypes.c_float(0) + + def iter_text_objs(parent, anc_mtx, depth): + """Yield (raw_text_obj, ancestor_matrix) in stream order.""" + object_count = (pdfium_c.FPDFFormObj_CountObjects(parent) if parent is not None + else pdfium_c.FPDFPage_CountObjects(page.raw)) + for text in range(object_count): + raw = (pdfium_c.FPDFFormObj_GetObject(parent, text) if parent is not None + else pdfium_c.FPDFPage_GetObject(page.raw, text)) + if not raw: + continue + typ = pdfium_c.FPDFPageObj_GetType(raw) + if typ == pdfium_c.FPDF_PAGEOBJ_TEXT: + yield raw, anc_mtx + elif typ == pdfium_c.FPDF_PAGEOBJ_FORM and depth < 10: + pdfium_c.FPDFPageObj_GetMatrix(raw, matrix) + font_matrix = (matrix.a, matrix.b, matrix.c, matrix.d, matrix.e, matrix.f) + yield from iter_text_objs(raw, _compose_mtx(font_matrix, anc_mtx), depth + 1) + + for raw_obj, anc_mtx in iter_text_objs(None, _IDENT_MTX, 0): + font = pdfium_c.FPDFTextObj_GetFont(raw_obj) + if not font: + continue + pdfium_c.FPDFTextObj_GetFontSize(raw_obj, ctypes.byref(sz_field)) + fs_raw = sz_field.value + pdfium_c.FPDFPageObj_GetMatrix(raw_obj, matrix) + # Effective (page-space) matrix: the object's own matrix composed with + # its ancestor forms' -- text extraction folds that ancestor chain into the text matrix. + matrix_a, matrix_b, matrix_c, matrix_d, _, _ = _compose_mtx( + (matrix.a, matrix.b, matrix.c, matrix.d, matrix.e, matrix.f), anc_mtx) + scale_x = math.sqrt(matrix_a * matrix_a + matrix_b * matrix_b) or 1.0 + scale_y = math.sqrt(matrix_c * matrix_c + matrix_d * matrix_d) or 1.0 + + if not pdfium_c.FPDFPageObj_GetBounds( + raw_obj, ctypes.byref(bounds_left), ctypes.byref(value), + ctypes.byref(bounds_right), ctypes.byref(bounds_top)): + continue + # Bounds include the object's own matrix but not its ancestors'; map + # the four corners into page space. + corners = [ + _xf_point(anc_mtx, corner_x, corner_y) + for corner_x in (bounds_left.value, bounds_right.value) for corner_y in (value.value, bounds_top.value) + ] + object_left = min(corner_x for corner_x, _ in corners) + object_right = max(corner_x for corner_x, _ in corners) + text = min(corner_y for _, corner_y in corners) + object_top = max(corner_y for _, corner_y in corners) + ink_height = max(0.0, object_top - text) + # text extraction folds Tfs (text font size) + FontMatrix into the text transform + # so ``hypot(transform[2], transform[3])`` always gives the + # rendered font size. PDFium splits these and doesn't fold non-identity + # FontMatrix back. Rendered-font-size fallback chain: + # raw >= 1.5 and scale > 0 -> raw * scale (normal text) + # scale >= 1.5 -> scale (Type 3: raw=0.1, ctm=N) + # raw >= 1.5 -> raw (no scale info) + # else -> ink_h (Type 3 inside identity ctm) + if anc_mtx is not _IDENT_MTX and fs_raw > 0 and scale_y > 0: + # Inside a Form XObject, span merger font size = hypot(trm[2],trm[3]) + # with the form CTM folded in = Tfs * composed scale, exactly + # (scaled vector-figure case: Tf 0.167 * 72 * form 0.5722 = + # 6.88 == the heading heuristics' item height; the placeholder chain below + # would misread it as Type-3-with-fs-in-ctm and emit 41pt boxes + # that swallow the neighbouring "2.2" heading). The chain stays + # for top-level objects, for top-level objects. + fs_eff = fs_raw * scale_y + elif fs_raw >= 1.5 and scale_y > 0: + fs_eff = fs_raw * scale_y + elif scale_y >= 1.5: + fs_eff = scale_y + elif fs_raw >= 1.5: + fs_eff = fs_raw + else: + fs_eff = max(1.0, ink_height) + # PDFium's FS_MATRIX is float32, so a size authored as 9.9pt arrives as + # 9.89999962; text extraction parses the content stream in float64 and keeps 9.9. + # Snap back to the shortest decimal so knife-edge font-size comparisons + # match the content-stream value. + fs_eff = float(f"{fs_eff:.6g}") + name = pdfium_c.FPDFFont_GetFontName(font, font_name_buffer, 256) + font_name = ( + bytes(font_name_buffer[:name]).decode("latin-1", errors="replace").rstrip("\x00") + if name > 1 else "" + ) + weight = int(pdfium_c.FPDFFont_GetWeight(font)) + + objects.append({ + "font": font, + "fs_raw": fs_raw, + "scale_x": scale_x, + "scale_y": scale_y, + "fs_eff": fs_eff, + "l": object_left, "r": object_right, "b": text, "t": object_top, + "area": max(0.0, (object_right - object_left) * (object_top - text)), + "font_name": font_name, + "weight": weight, + # Rotation class of this text object (0/90/180/270, or -1 oblique). + # text extraction normalises it inside position comparison; the charlevel + # merger is horizontal-only, so cardinal runs (rotated-sidebar sidebar stamp, + # chart axis labels) shatter per-glyph and are re-merged by + # _remerge_rotated; oblique objects go to _remerge_oblique (needs the + # matrix below for the inverse-rotation projection baseline projection). + "rot": _obj_rotation(matrix_a, matrix_b, matrix_c, matrix_d), + "mtx": (matrix_a, matrix_b, matrix_c, matrix_d), + # Paint (content-stream) order. text extraction emits items in stream order but + # PDFium's textpage reorders vertical-writing chars page-wide, so + # _remerge_vertical needs this to restore text extraction item order. + "page_order": len(objects), + # True iff this object's show-op used a vertical-CMap (-V / WMode 1) + # font -- span merger vertical-font flag. Set by _assign_vertical_tags. + "vertical": False, + # Show-op text horizontal scale (Tz/100). text extraction keeps Tz OUT of the space + # thresholds (base = raw font size) while PDFium folds it into the + # object matrix (hence into fs_x); open_chunk divides it back out. + # Set by _assign_show_tz via the same ordinal alignment as + # ``vertical``; stays 1.0 on a count mismatch. + "tz": 1.0, + }) + return objects + + +def _build_obj_index(objects: list[dict]) -> dict[int, list[dict]]: + """Bucket text objects by integer y so per-character lookup scans only nearby baselines. Each object is inserted into padded y-buckets that form a superset for the exact containment check.""" + index: dict[int, list[dict]] = {} + for item_value in objects: + lower_bound = int(math.floor(item_value["b"])) - 6 + upper_bound = int(math.ceil(item_value["t"])) + 6 + for text_key in range(lower_bound, upper_bound + 1): + index.setdefault(text_key, []).append(item_value) + return index + + +def _char_render_fs(text_page, char_idx: int) -> float: + """True per-char rendered size: ``FPDFText_GetMatrix`` folds Tfs and FontMatrix into the rendered text matrix, so ``sqrt(c^2+d^2)`` is the text-item height. Returns 0.0 when the call is unavailable. Read lazily, only when a char is contained by more than one object, since the FFI call is expensive and most chars have a single, unambiguous host object.""" + current_matrix = pdfium_c.FS_MATRIX() + if pdfium_c.FPDFText_GetMatrix(text_page, char_idx, ctypes.byref(current_matrix)): + return math.sqrt(current_matrix.c * current_matrix.c + current_matrix.d * current_matrix.d) + return 0.0 + + +def _find_obj_for_char( + obj_index: dict[int, list[dict]], query_origin_x: float, query_origin_y: float, tol: float = 1.0, + char_fs: float | None = None, text_page=None, char_idx: int | None = None, +) -> dict | None: + """Bbox containment lookup. When a char falls inside more than one text object, pick the candidate whose effective rendered size matches the char's true per-char matrix size from ``FPDFText_GetMatrix``. That folds Tfs and FontMatrix into the same glyph-to-font attribution used by the text-item reconstruction. This disambiguates overlapping objects such as a large figure-axis label drawn over a smaller heading, and avoids selecting tiny ghost objects that share the same raw textpage font size. Falls back to the PDFium ``fs_raw`` textpage font size and finally to smallest area.""" + cands: list[dict] = [] + for item_value in obj_index.get(int(round(query_origin_y)), ()): + if (item_value["l"] - tol) <= query_origin_x <= (item_value["r"] + tol) and\ + (item_value["b"] - tol) <= query_origin_y <= (item_value["t"] + tol): + cands.append(item_value) + if not cands: + return None + if len(cands) == 1: + return cands[0] + char_render = ( + _char_render_fs(text_page, char_idx) if text_page is not None and char_idx is not None + else 0.0 + ) + if char_render > 0: + # Match the per-char rendered size (== text extraction font size); area tiebreak. + return min( + cands, + key=lambda item_value: (abs(item_value["fs_eff"] - char_render), item_value["area"]), + ) + if char_fs is not None and char_fs > 0: + # Sort by absolute fs diff first, then smallest area as tiebreak. + return min( + cands, + key=lambda item_value: (abs(item_value["fs_raw"] - char_fs) / max(char_fs, 0.01), item_value["area"]), + ) + return min(cands, key=lambda item_value: item_value["area"]) diff --git a/pageindex/flash/parser_pdfium_charlevel/glyph_tables.py b/pageindex/flash/parser_pdfium_charlevel/glyph_tables.py new file mode 100644 index 000000000..efacd64f2 --- /dev/null +++ b/pageindex/flash/parser_pdfium_charlevel/glyph_tables.py @@ -0,0 +1,82 @@ +"""Bundled glyph-name and encoding tables with cached lazy loading.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from .cmap_parse import _parse_int + + +# --------------------------------------------------------------------------- +# Font Unicode-map construction. +# +# PDFium's per-character Unicode can diverge when a simple font's ToUnicode CMap +# is missing or incomplete. The repair path resolves the charcode through the +# font's encoding (dictionary /Encoding BaseEncoding+Differences, or an embedded +# Type1 program's builtin encoding) to a glyph name, maps that name through the +# bundled glyph table, and otherwise falls back to the raw charcode. The map is +# rebuilt from the PDF's own font dictionaries via the PyPDF2 xref channel +# (font metadata only, no text decode), then applied where PDFium's output +# disagrees. +# +# Covered rules: encoding and Differences extraction, simple-font Unicode-map +# construction, predefined collection Unicode-map construction, ToUnicode +# parsing, fallback Unicode-map repair, Type 1 Unicode-map repair, and glyph +# mapping as the included ToUnicode value when present, otherwise the raw charcode. + +# /Encoding extraction from an embedded Type1 file +# Glyph-name Unicode lookup +# glyph names and standard encodings are bundled in data/glyph_name_table.json +# (kept deliberately conservative +# +# Boundaries (documented, all conservative -- no map entry means no patch): +# - composite (Type0) fonts: separate path, never patched here; +# - CFF (FontFile3) builtin encodings: not parsed here; dict-encoding-based +# mapping still applies; +# - symbolic-TrueType WinAnsi inference (content stream tokenizer TrueType Unicode-map repair): +# needs the TTF name records, not implemented. +# --------------------------------------------------------------------------- + +_GLYPHLIST_PATH = Path(__file__).parent.parent / "data" / "glyph_name_table.json" +_cached_glyphs: dict[str, int] | None = None +_cached_encodings: dict[str, list[str]] | None = None + + +def _load_glyph_tables() -> tuple[dict[str, int], dict[str, list[str]]]: + global _cached_glyphs, _cached_encodings + glyphs, encodings = _cached_glyphs, _cached_encodings + if glyphs is None or encodings is None: + data = json.loads(_GLYPHLIST_PATH.read_text(encoding="utf-8")) + glyphs = _cached_glyphs = data["glyphs"] + encodings = _cached_encodings = data["encodings"] + return glyphs, encodings + + +def _get_unicode_for_glyph(name: str, glyphs: dict[str, int]) -> int: + """Resolve a glyph name through glyphlist lookup and uppercase-hex recovery patterns.""" + codepoint = glyphs.get(name) + if codepoint is not None: + return codepoint + if not name: + return -1 + if name[0] == "u": + glyph_name_length = len(name) + if glyph_name_length == 7 and name[1] == "n" and name[2] == "i": + hex_str = name[3:] + elif 5 <= glyph_name_length <= 7: + hex_str = name[1:] + else: + return -1 + if hex_str == hex_str.upper(): + # Tolerant base-16 parsing trims Unicode whitespace and accepts an + # optional sign / 0X prefix. NaN fails the >= 0 gate; "-0" passes it. + u16 = _parse_int(hex_str, 16) + if u16 >= 0: + return int(u16) + return -1 + + +def _from_char_code(number: int) -> str: + """Return the UTF-16 code unit after ToUint16 truncation.""" + return chr(number & 0xFFFF) diff --git a/pageindex/flash/parser_pdfium_charlevel/merge.py b/pageindex/flash/parser_pdfium_charlevel/merge.py new file mode 100644 index 000000000..dab9dd7b4 --- /dev/null +++ b/pageindex/flash/parser_pdfium_charlevel/merge.py @@ -0,0 +1,511 @@ +"""Joins page glyphs into text runs with spacing and style thresholds.""" + +from __future__ import annotations + +from .text_normalize import ( + TRACKING_SPACE_FACTOR, + NON_SPACE_GAP_FACTOR, + NEGATIVE_SPACE_FACTOR, + SPACE_IN_FLOW_MIN_FACTOR, + SPACE_IN_FLOW_MAX_FACTOR, + _rtl_sign, + _read_end, + _read_gap, +) +from .char_extract import _off_page + + +def _merge_text_items(chars: list[dict], view_box=None) -> list[dict]: + """exact text extraction position comparison + synthetic-space insertion + last-character buffer.""" + items: list[dict] = [] + chunk: dict | None = None + two_last = [" ", " "] + two_last_pos = [0] + # text extraction active text item.previous glyph transform: set only by a glyph with a real + # advance (`if (scaled advance)`), NEVER reset by text-item flush/setFont + # -- it survives across item flushes for the whole page. (None, None) + # until the first real glyph. + last_ref: tuple = (None, None) + + def _object_merge_id(mapping: dict): + # Per-object merge id: the boundary test below hard-splits between + # different objects (the per-object split). q/Q grouping would require + # fragile object/show-op ordinal alignment, so this is just the object's + # identity. + return id(mapping["obj"]) + + def reset_last_chars() -> None: + two_last[0] = " " + two_last[1] = " " + two_last_pos[0] = 0 + + def save_last_char(char: str) -> bool: + next_pos = (two_last_pos[0] + 1) % 2 + ret = (two_last[two_last_pos[0]] != " " and two_last[next_pos] == " ") + two_last[two_last_pos[0]] = char + two_last_pos[0] = next_pos + return ret + + def flush() -> None: + nonlocal chunk + if chunk is not None and chunk["str"]: + items.append(chunk) + chunk = None + + def open_chunk(mapping: dict) -> None: + nonlocal chunk, last_ref + sign = _rtl_sign(mapping["ch"]) + # |text horizontal scale|: fs_x = |matrix scale| carries |Tz|, so the divisor is + # the magnitude (a negative Tz uses the same text but scales it by |Tz|). + # Tz == 0 keeps 1.0 (moot: PDFium emits no textpage chars for a + # degenerate x-column). Boundary conditions: + # PDFium's SYNTHESIZED layout spaces derive from the unscaled + # text-space gap (~0.135em), so compressed Tz < ~75 can inject + # spaces text extraction would not; negative-Tz runs re-merge via the + # 180-degree pass with different item structure than span merger' + # text orientation=-1 model; anisotropic CTM x rotated Tm differs + # (norm-of-product vs span merger product-of-norms text advance scale). + horizontal_scale_factor = abs(mapping["obj"].get("tz", 1.0)) + if not (horizontal_scale_factor > 0): + horizontal_scale_factor = 1.0 + chunk = { + "str": [], + "sign": sign, # +1 LTR, -1 RTL (signed x-axis) + "obj": mapping["obj"], # host text object (Tj/show-text) + # text extraction fixes item transform at the item's FIRST glyph + # (item initialization) and never updates it mid-item, while + # chunk["obj"] re-points to the LAST appended glyph's object (the + # flush/prose bookkeeping needs that). Snapshot the opening + # object's matrix so the emitted skew reads first-glyph geometry. + "mtx0": mapping["obj"]["mtx"], + "flush_id": _object_merge_id(mapping), # per-object merge id (was q/Q flush scope) + "left": mapping["left"], "right": mapping["right"], + "top": mapping["top"], "bottom": mapping["bottom"], + "fs": mapping["fs"], + "fs_min": mapping["fs"], + # Glyph advance (FPDFFont_GetGlyphWidth*scale). Unused by the + # horizontal merger (it reads prev_text_x); carried only so + # _remerge_rotated can run a direct 1-D position comparison + # (gap = next_origin - (cur_origin + glyph_w)) along the rotation axis. + "glyph_w": mapping.get("glyph_w", 0.0), + "font_name": mapping["font_name"], + "font_key": mapping["font_key"], + "weight": mapping["weight"], + # Per-char style tallies for majority-vote at span emission. + # span merger text item records only the first char's font name; + # the heading heuristics' heading detection ends up marking paragraph + # lead-ins like **Bold prefix.** Regular continuation as + # "bold lines" because of that. Tally per-char so we can + # emit the dominant font/weight instead. + "font_tally": {mapping["font_name"]: 1}, + "weight_tally": {mapping["weight"]: 1}, + # ``prev_text_x`` tracks where the next glyph would land if + # charSpacing=0 — i.e. text matrix.e after this glyph's emit. + # For ligature components, PDFium reports them at the same + # origin but with bbox spanning the full ligature, so taking + # max(ox+glyph_w, bbox.right) makes the next non-ligature + # char see a small positive advance instead of a big gap. + # (_read_end uses the same this for an RTL chunk.) + "prev_text_x": _read_end(mapping, sign), + "prev_oy": mapping["oy"], + # text extraction threshold base is text state.font size WITHOUT Tz + # (item initialization: Tz enters only the pen advance, not + # text advance scale). PDFium folds Tz into the object matrix, so + # fs_x carries it; divide the show-op's text horizontal scale back out. + "tracking": mapping["fs_x"] / horizontal_scale_factor * TRACKING_SPACE_FACTOR, + "not_a_space": mapping["fs_x"] / horizontal_scale_factor * NON_SPACE_GAP_FACTOR, + "negative": mapping["fs_x"] / horizontal_scale_factor * NEGATIVE_SPACE_FACTOR, + "flow_min": mapping["fs_x"] / horizontal_scale_factor * SPACE_IN_FLOW_MIN_FACTOR, + "flow_max": mapping["fs_x"] / horizontal_scale_factor * SPACE_IN_FLOW_MAX_FACTOR, + "height": mapping["fs"], + # True once a real whitespace glyph follows the last visible glyph in + # this chunk; gates whether an object boundary is a prose word-break + # (merge) or a layout jump (hard split). See the is_ws handler. + "ws_pending": False, + } + if "v_pen_y" in mapping: + # Vertical-writing pen state for _remerge_vertical (set only for + # vertical-CMap objects). + chunk["v_pen_x"] = mapping["v_pen_x"] + chunk["v_pen_y"] = mapping["v_pen_y"] + chunk["v_after"] = mapping["v_after"] + chunk["v_last_x"] = mapping["v_pen_x"] + if mapping["is_mn"]: + # A zero-width diacritic has scaled advance == 0, so it does NOT + # establish the advance reference; the chunk INHERITS the + # page-surviving one (text extraction previous glyph transform persists across + # flushes; (None, None) only until the page's first real glyph). + chunk["prev_text_x"], chunk["prev_oy"] = last_ref + else: + last_ref = (chunk["prev_text_x"], chunk["prev_oy"]) + + def emit_fake_space(gap: float) -> None: + """Emit an out-of-flow synthetic space after the current chunk. The synthetic item uses the previous glyph transform, not the next glyph, so the space remains attached to the line it trails. Its height stays zero; otherwise vertical-alignment checks can attach the space to a neighboring line and create a spurious line merge. """ + assert chunk is not None + reset_last_chars() # text extraction synthetic-space insertion standalone path resets first + page_x, baseline = chunk["prev_text_x"], chunk["prev_oy"] + # WIDTH = abs(gap). Synthetic out-of-flow spaces use ``width: abs(e)``, + # where e is the out-of-flow advance (the gap it + # spans), height 0 for horizontal text. The box therefore runs from the + # previous glyph's pen end (px) forward by the gap: [px, px+gap] LTR, + # [px-gap, px] RTL. In-flow spaces are handled by pushing " " into the + # current item; standalone spaces use this separate geometry. Height + # stays 0, so vertical-alignment guards are + # untouched and the outline is unaffected. + width_value = abs(gap) + if chunk["sign"] >= 0: + sp_left, sp_right = page_x, page_x + width_value + else: + sp_left, sp_right = page_x - width_value, page_x + meta = (chunk["obj"], chunk["fs"], chunk["font_name"], + chunk["font_key"], chunk["weight"]) + flush() + items.append({ + "str": [" "], "sign": 1, "obj": meta[0], + "left": sp_left, "right": sp_right, + "top": baseline, "bottom": baseline, # HEIGHT 0 + "fs": meta[1], "fs_min": meta[1], + "font_name": meta[2], "font_key": meta[3], "weight": meta[4], + "font_tally": {meta[2]: 1}, "weight_tally": {meta[4]: 1}, + }) + + def extend_chunk(mapping: dict, leading_space: bool) -> None: + nonlocal last_ref + assert chunk is not None + if leading_space: + chunk["str"].append(" ") + chunk["str"].append(mapping["ch"]) + chunk["left"] = min(chunk["left"], mapping["left"]) + chunk["right"] = max(chunk["right"], mapping["right"]) + # Text-item box accumulation: appending a glyph only grows the item's + # width. The item's vertical box is + # fixed at item creation -- transform[5] = first-glyph baseline, and + # height = font size (== the item's em). A per-glyph baseline offset + # within the item (e.g. a lowered character inside a mixed-baseline + # logo, or any sub/superscript not split into its own item) is therefore + # absorbed: it does NOT extend the item box. Do not expand top/bottom + # here; they stay at the open glyph's [oy, oy+fs]. Expanding them would + # let inline baseline offsets distort downstream line-height gates. + chunk["prev_text_x"] = _read_end(mapping, chunk["sign"]) + chunk["prev_oy"] = mapping["oy"] + last_ref = (chunk["prev_text_x"], chunk["prev_oy"]) + chunk["glyph_w"] = mapping.get("glyph_w", 0.0) # last glyph's advance (for _remerge_rotated) + # When a real-space word-break us merge across a text-object boundary + # (prose case), the chunk must adopt the new object so the rest of that + # word's glyphs (same object, no space before them) don't re-trigger the + # object hard-split mid-word. span merger line item has no per-glyph object. + chunk["obj"] = mapping["obj"] + chunk["flush_id"] = _object_merge_id(mapping) + chunk["font_tally"][mapping["font_name"]] = chunk["font_tally"].get(mapping["font_name"], 0) + 1 + chunk["weight_tally"][mapping["weight"]] = chunk["weight_tally"].get(mapping["weight"], 0) + 1 + # Track min fs within the chunk so small-caps headings ("A"+ + # "BSTRACT") expose the body-text fs of the small-cap part + # rather than the leading full-cap fs. The downstream big-font + # check then doesn't false-positive on inline math labels like + # "LEMMA 1" whose small-cap fs is below body size. + if mapping["fs"] > 0: + chunk["fs_min"] = min(chunk["fs_min"], mapping["fs"]) + if "v_pen_y" in mapping and "v_pen_y" in chunk: + chunk["v_after"] = mapping["v_after"] + chunk["v_last_x"] = mapping["v_pen_x"] + + for text in chars: + # text extraction text-item box accumulation char loop order (span merger+): + # invisible format-mark classification is skipped entirely BEFORE the whitespace test. + if text["is_cf"]: + continue + if text["is_ws"]: + save_last_char(" ") + # Remember a real whitespace glyph bridged the gap. PDFium fragments a + # flowing prose line into per-word text-objects (each with a trailing + # space glyph); text extraction keeps the whole line as one Tj item. A real space + # at an object boundary marks a prose word-break -> merge across it. + # A positional (spaceless) object change marks a layout jump (table + # cell, separate Tj) -> keep the hard object split. + if chunk is not None: + chunk["ws_pending"] = True + continue + # Zero-width diacritics append without a position-based flush: they do NOT call + # position comparison (no position-based flush) and uses + # scaled advance=0 (no advance) -- it just appends the mark to the + # current item. Append it without touching prev_text_x. + # BUT a Tf style flush is an operator-level split that already closed + # the previous item before the glyph loop ran, so a mark arriving in a + # different font/size (for example, a math accent over an italic letter, + # letter, each its own Tf'd show op) opens its OWN item, with its own + # raised origin and em height. Only + # the position-based flush is skipped for diacritics, never the style + # flush, so the mark passes the same font_key/fs/object boundary test + # as any visible glyph. + if text["is_mn"]: + if chunk is not None and ( + chunk["font_key"] != text["font_key"] + or abs(text["fs"] - chunk["fs"]) > 1e-6 + or (_object_merge_id(text) != chunk["flush_id"] and not chunk["ws_pending"]) + ): + flush() + if chunk is None: + # open_chunk inherits the page-surviving advance reference + # (text extraction previous glyph transform persists across flushes; None only at + # page start -- see the prev_text_x-is-None guard below). + open_chunk(text) + assert chunk is not None + lead = save_last_char(text["ch"]) + # the heading heuristics pushes the last-character buffer lead into the fresh mark item + # (a Tf flush does not reset the last-character buffer). + if lead: + chunk["str"].append(" ") + chunk["str"].append(text["ch"]) + else: + lead = save_last_char(text["ch"]) + if lead: + chunk["str"].append(" ") + chunk["str"].append(text["ch"]) + # span merger: a zero-width diacritic has scaled advance=0, so it neither + # moves the text matrix NOR updates previous glyph transform (span merger + # `if (scaled advance)` is false). The next glyph's line-break / + # dy test therefore compares against the last VISIBLE glyph's + # baseline -> leave BOTH prev_text_x and prev_oy untouched here + # (the box also stays the open glyph's -- see extend_chunk note). + continue + + # span merger: a non-diacritic glyph whose origin is off the page view box is + # skipped (position comparison returns false only off-page). cf/ws + # were handled above and diacritics (is_mn) never reach here, matching + # span merger `!zero-width diacritic classification and !position comparison`. + + if _off_page(text, view_box): + continue + + if chunk is None: + open_chunk(text) + save_last_char(text["ch"]) + assert chunk is not None + chunk["str"].append(text["ch"]) + continue + + # Style boundary: split on font-identity change (font_key, the PDFium + # font handle == span merger per-font loaded font identity) OR ANY font size change. + # span merger emit a separate text item on every setFont + # (Tf) operator -- i.e. on any font OR size change. Represent + + # that with an exact effective-fs compare (the 1e-6 is only to absorb + # float noise in the snapped fs). An earlier 10% tolerance under-split + # small-caps runs; exact is intentional here. + + # font_key/fs is a proxy for the Tf flush, not a literal replay of every + # content-stream flush boundary. Text-item boundaries are driven mostly + # by position comparison; PDFium exposes final glyph coordinates, so the + # per-object + font_key/fs proxy gives the heading pipeline the intended + # span structure without overfitting to partial operator state. The + # remaining boundary cases, such as missing-glyph fallback handles or + # rendered-size jitter under scaled Type-3 matrices, are limited to span + # boundaries. + # The heading heuristics' downstream heading detector then + # treats a chunk's first-char style as the whole chunk's style: + # + # * font split: a paragraph lead-in like "**Inflation.** + # Consumer price..." would otherwise be a single bold chunk + # and false-detect as a heading on every paragraph. (font_name + # alone can't separate identity-matrix Type-3 fonts, whose names + # are all empty, so a 12pt body run and an inline 11pt code word + # would merge and collapse to the smaller fs_min.) + # * fs split: inline math labels like "LEMMA 1 (...) ..." + # (first-cap large + small-cap rest + body) would otherwise + # merge into a single chunk that pipeline accepts as a + # heading; splitting forces the small-cap rest into its own + # chunk where the heading heuristics' short-text/type checks reject it. Same + # guard also helps math-heavy page/identity-matrix Type-3 sample exercise items + # ("X.Y www") and section headings stay detectable — + # without it they collapse into the surrounding body chunk. + # + # Trade-off: small-caps "ABSTRACT" / "ECONOMIC ANALYSIS" + # don't merge across the cap-to-small-cap fs step. The + # downstream tokenizer relaxation (LineTokenizer.add_line below) + # joins them at token level instead. + ws_bridge = chunk["ws_pending"] + chunk["ws_pending"] = False + if chunk["prev_text_x"] is None: + # The item was opened by a zero-width diacritic AT PAGE START (no + # real glyph has set the page's advance reference yet, so span merger' + # previous glyph transform is still null): position comparison returns + # true unconditionally -- no positional boundary, no fake space, + # no line break. Only the style flush (the Tf proxy) still + # applies; otherwise the glyph appends plainly and, being a real + # advance, establishes the reference via extend_chunk. + if (chunk["font_key"] != text["font_key"] + or abs(text["fs"] - chunk["fs"]) > 1e-6): + flush() + open_chunk(text) + lead = save_last_char(text["ch"]) + assert chunk is not None + if lead: # Detail: no reset on this path, the lead survives + + chunk["str"].append(" ") + chunk["str"].append(text["ch"]) + else: + extend_chunk(text, save_last_char(text["ch"])) + continue + if ( + chunk["font_key"] != text["font_key"] + or abs(text["fs"] - chunk["fs"]) > 1e-6 + # Hard-split at a text-object boundary -- but ONLY when no real + # whitespace glyph bridged it. the object merge id groups consecutive per-glyph show operators + # objects (PDFium emits one FPDF_PAGEOBJ_TEXT per glyph when the PDF + # draws glyphs individually) into one id, so a CJK title set as N + # per-glyph Tj does NOT shatter into N single-glyph items -- the + # positional logic below merges it / line-breaks it like the item merger. + # Every normal (multi-glyph) object keeps its own id, so this stays + # the per-object split for Latin text: on dense justified tables (2023 + # dense table document) each fragment is its own object -> hard split, exact + # with the item merger. On flowing prose PDFium may split + # per word with a real space glyph between words, where text extraction keeps + # the whole line as one item; a real space at the boundary (ws_bridge) + # marks the prose case -> fall through to the in-flow/out-of-flow gap + # logic, which merges the word-objects into one line item like span merger. + or (_object_merge_id(text) != chunk["flush_id"] and not ws_bridge) + ): + # span merger position comparison runs synthetic-space insertion for EVERY glyph, + # including the first glyph of a new item/Tj. So an out-of-flow gap + # across an item boundary still gets a standalone height-0 " " + # (this is the trailing space after math-heavy page's "...y)" before the next + # equation-number object). An in-flow / adjacent boundary does not. + # text extraction position comparison order: a line break (|advance-y| > + # height -> line-break emission) or backward jump takes precedence over the + # space logic; only a same-line gap past tracking-space threshold emits the + # standalone " " (covering BOTH the in-flow empty-item case and + # the out-of-flow synthetic-space insertion case, which are identical here). + boundary_gap = _read_gap(chunk["prev_text_x"], text, chunk["sign"]) + _same_line = abs(text["oy"] - chunk["prev_oy"]) <= chunk["height"] + if _same_line and boundary_gap > chunk["tracking"]: + emit_fake_space(boundary_gap) + keep_lead = False # the heading heuristics synthetic-space insertion reset the last-character buffer + else: + # the heading heuristics resets the two-char buffer on every positional branch + # (line-break emission / negative / non-space) but NOT in the tracking + # window (non-space, tracking-space threshold] -- a thin real space + # just before a Tf-style flush survives into the new item. + keep_lead = (_same_line + and chunk["not_a_space"] < boundary_gap <= chunk["tracking"]) + flush() + open_chunk(text) + lead = save_last_char(text["ch"]) + assert chunk is not None + if lead and keep_lead: + chunk["str"].append(" ") + chunk["str"].append(text["ch"]) + continue + + advance = _read_gap(chunk["prev_text_x"], text, chunk["sign"]) + line_delta_y = text["oy"] - chunk["prev_oy"] + height = chunk["height"] + + # Ligature decomposition: PDFium reports consecutive ligature + # components at the same x origin (e.g. "fi" -> 'f' and 'i' at the + # identical origin). prev_text_x was set to prev.ox + + # prev.glyph_w, so we see advance ≈ -prev.glyph_w. Glyph widths + # of typical Latin chars are in [0.2*fs, 0.9*fs]. Detect this + # case (negative advance whose magnitude is in that range) and + # silently merge — matches span merger on same-origin + # ligature components. ONLY within one text object: decomposition + # is per-glyph, so both components always share the show op. A + # cross-object negative advance is a real content-stream back-jump + # that text extraction itself sees and breaks on (TeX standalone accents: + # math-heavy page 'accented name stem'+'¨'+'lkopf' is three show ops, '¨' jumps back -0.41fs; + # text extraction raw-categorizes U+00A8 as a normal glyph -- category comes + # from glyph Unicode BEFORE the normalized Unicode expansion -- so + # position comparison flushes and ' ̈lkopf' opens a new item). + if (text["obj"] is chunk["obj"] and abs(line_delta_y) < 0.1 * height + and -0.9 * chunk["fs"] <= advance < -0.2 * chunk["fs"]): + lead = save_last_char(text["ch"]) + # Don't extend prev_text_x backwards; ligature component + # shares position with prev, so prev_text_x stays the same. + if lead: + chunk["str"].append(" ") + chunk["str"].append(text["ch"]) + chunk["left"] = min(chunk["left"], text["left"]) + chunk["right"] = max(chunk["right"], text["right"]) + # Ligature component shares the open glyph's item box; only width + # grows (see extend_chunk note -- text extraction never expands the item's + # vertical extent on append). + chunk["prev_oy"] = text["oy"] + last_ref = (chunk["prev_text_x"], chunk["prev_oy"]) + continue + + # text extraction position comparison compares advance-x against + # ``text orientation * threshold`` (text orientation = sign(item.width)). + # We get the same result for HORIZONTAL text by normalising the gap into + # READING DIRECTION up front: ``advance`` (= _read_gap with chunk["sign"] + # from _rtl_sign) is already signed so that "forward" is positive for BOTH + # LTR and RTL, hence the thresholds below are compared UNMULTIPLIED. + # * LTR (sign=+1): intentional to the literal layout-classifier form. + + # * horizontal RTL (Hebrew/Arabic, sign=-1): handled via the x-axis + # paired logic in _read_end/_read_gap (added in 528f958). + # VERTICAL text (span merger vertical-font flag / advance-y branch) is NOT handled + # here: a vertical column shatters per-glyph below and is re-merged by + # the gated _remerge_vertical post-pass (detection: vertical-CMap fonts + # via _page_vertical_resnames; matched against the item merger's + # vertical-text rules. + if advance < chunk["negative"]: + if abs(line_delta_y) > 0.5 * height: + # the heading heuristics line-break emission calls reset the last-character buffer before flushing. + reset_last_chars() + flush() + open_chunk(text) + save_last_char(text["ch"]) + assert chunk is not None + chunk["str"].append(text["ch"]) + else: + reset_last_chars() + flush() + open_chunk(text) + save_last_char(text["ch"]) + assert chunk is not None + chunk["str"].append(text["ch"]) + continue + + if abs(line_delta_y) > height: + # the heading heuristics line-break emission calls reset the last-character buffer before flushing. + reset_last_chars() + flush() + open_chunk(text) + save_last_char(text["ch"]) + assert chunk is not None + chunk["str"].append(text["ch"]) + continue + + if advance <= chunk["not_a_space"]: + reset_last_chars() + + if advance <= chunk["tracking"]: + lead = save_last_char(text["ch"]) + extend_chunk(text, lead) + continue + + if chunk["flow_min"] <= advance <= chunk["flow_max"]: + reset_last_chars() + chunk["str"].append(" ") + lead = save_last_char(text["ch"]) + extend_chunk(text, lead) + continue + + # OUT-OF-FLOW gap (advance > in-flow space threshold): text extraction synthetic-space insertion + # flushes the current item and pushes a + # STANDALONE " " item with height 0, then a new item begins at this glyph. + # The zero height is load-bearing: the heading heuristics' vertical-alignment test + # can't align this inter-run space with a neighbouring line, so it doesn't + # cause a spurious line merge (the math-heavy page inline math heading heading drop). The + # standalone " " also keeps the word separator in the joined line text Yf + # so a positionally-spaced title like "3 The section heading" + # (Type-3 fonts, no real space glyphs) does not collapse to + # "3TheStaticSemantics" and lose its section number to the _Tf regex. + reset_last_chars() + emit_fake_space(advance) # standalone height-0 " ", width=abs(gap) (exact span merger) + open_chunk(text) + save_last_char(text["ch"]) + assert chunk is not None + chunk["str"].append(text["ch"]) + + flush() + return items diff --git a/pageindex/flash/parser_pdfium_charlevel/pdf_objects.py b/pageindex/flash/parser_pdfium_charlevel/pdf_objects.py new file mode 100644 index 000000000..cf9b3bbb6 --- /dev/null +++ b/pageindex/flash/parser_pdfium_charlevel/pdf_objects.py @@ -0,0 +1,191 @@ +"""Raw PDF object access (PyPDF2-backed) and PDF lexical primitives.""" + +from __future__ import annotations + +from PyPDF2.generic import ( + IndirectObject as PdfIndirectRef, NameObject as PdfName, NumberObject as PdfNumber, + FloatObject as PdfFloat, BooleanObject as PdfBoolean, + DictionaryObject as PdfDictionary, ArrayObject as PdfArray, +) + + +def _pdf_tok(value) -> str: + """Serialise one PDF value back to content-syntax (for xref_object's regex).""" + if isinstance(value, PdfIndirectRef): + return f"{value.idnum} {value.generation} R" + if isinstance(value, PdfName): + return str(value) + if isinstance(value, PdfBoolean): + return "true" if value.value else "false" + if isinstance(value, PdfDictionary): + return _pdf_obj_str(value) + if isinstance(value, PdfArray): + return "[ " + " ".join(_pdf_tok(array_item) for array_item in value) + " ]" + return str(value) + + +def _pdf_obj_str(obj) -> str: + """Serialize an object body as a PDF-syntax string.""" + if isinstance(obj, PdfIndirectRef): + obj = obj.get_object() + if isinstance(obj, PdfDictionary): + parts = ["<<"] + for key_value, val in obj.items(): + parts.append(str(key_value)) + parts.append(_pdf_tok(val)) + parts.append(">>") + return " ".join(parts) + if isinstance(obj, PdfArray): + return "[ " + " ".join(_pdf_tok(array_item) for array_item in obj) + " ]" + return _pdf_tok(obj) + + +def _pdf_typed(value): + """Return ``(type, value-string)`` for a raw, unresolved PDF value.""" + if value is None: + return ("null", "null") + if isinstance(value, PdfIndirectRef): + return ("xref", f"{value.idnum} {value.generation} R") + if isinstance(value, PdfName): + return ("name", str(value)) + if isinstance(value, PdfBoolean): + return ("bool", "true" if value.value else "false") + if isinstance(value, PdfFloat): + return ("real", str(value)) + if isinstance(value, PdfNumber): + return ("int", str(int(value))) + if isinstance(value, PdfDictionary): + return ("dict", _pdf_obj_str(value)) + if isinstance(value, PdfArray): + return ("array", _pdf_obj_str(value)) + try: + return ("string", str(value)) + except Exception: + return ("null", "null") + + +class _PdfPage: + __slots__ = ("_page_object",) + + def __init__(self, page): + self._page_object = page + + def read_contents(self) -> bytes: + candidate_item = self._page_object.get_contents() + if candidate_item is None: + return b"" + if isinstance(candidate_item, PdfIndirectRef): + candidate_item = candidate_item.get_object() + if hasattr(candidate_item, "get_data"): + return candidate_item.get_data() + # /Contents is an array of streams; concatenate them with a single + # space (intentional); join the raw decompressed data the same. + + return b" ".join(text.get_object().get_data() for text in candidate_item) + + def get_fonts(self, full: bool = True): + out: list = [] + res = self._page_object.get("/Resources") + if res is None: + return out + fonts = res.get_object().get("/Font") + if fonts is None: + return out + for _xref_key, ref in fonts.get_object().items(): + idnum = ref.idnum if isinstance(ref, PdfIndirectRef) else 0 + filter_context = ref.get_object() + subtype = str(filter_context.get("/Subtype", "")).lstrip("/") + basefont = str(filter_context.get("/BaseFont", "")).lstrip("/") + enc_raw = filter_context.raw_get("/Encoding") if "/Encoding" in filter_context else None + enc = str(enc_raw).lstrip("/") if isinstance(enc_raw, PdfName) else "" + out.append((idnum, "", subtype, basefont, str(_xref_key).lstrip("/"), enc)) + return out + + +class _PdfDoc: + """PyPDF2-backed adapter for raw object and stream access PDFium cannot expose.""" + + __slots__ = ("_reader", "_virtual") + + def __init__(self, reader): + self._reader = reader + # Negative pseudo-xrefs for DIRECT (inline) dicts that have no object + # number -- text extraction reference resolution treats direct and indirect values alike, + # so inline font dicts must be addressable by the same integer-keyed + # pipeline (_redefinition_dict_xrefs registers them). + self._virtual: dict[int, object] = {} + + def register_virtual(self, obj) -> int: + vid = -(len(self._virtual) + 1) + self._virtual[vid] = obj + return vid + + @property + def page_count(self) -> int: + return len(self._reader.pages) + + def __getitem__(self, idx): + return _PdfPage(self._reader.pages[idx]) + + def page_xref(self, idx: int) -> int: + return self._reader.pages[idx].indirect_reference.idnum + + def _resolve_object(self, xref: int): + if xref < 0: + return self._virtual.get(xref) + return PdfIndirectRef(xref, 0, self._reader).get_object() + + def xref_get_key(self, xref: int, _xref_key: str): + cur = self._resolve_object(xref) + parts = _xref_key.split("/") + for index_value, part in enumerate(parts): + if cur is None: + return ("null", "null") + if isinstance(cur, PdfIndirectRef): + cur = cur.get_object() + if not hasattr(cur, "raw_get"): + return ("null", "null") + name = "/" + part + if name not in cur: + return ("null", "null") + if index_value == len(parts) - 1: + return _pdf_typed(cur.raw_get(name)) + cur = cur[name] + return _pdf_typed(cur) + + def xref_stream(self, xref: int) -> bytes: + return self._resolve_object(xref).get_data() + + def xref_object(self, xref: int, compressed: bool = True) -> str: + return _pdf_obj_str(self._resolve_object(xref)) + + def close(self) -> None: + try: + self._reader.stream.close() + except Exception: + pass +_PDF_WHITESPACE_BYTES = bytes({0x20, 0x09, 0x0d, 0x0a, 0x0c, 0x00}) +_PDF_DELIMITER_BYTES = b"()<>[]{}/%" + + +_PDF_STRING_ESCAPE_BYTES = {0x6E: 0x0A, 0x72: 0x0D, 0x74: 0x09, 0x62: 0x08, 0x66: 0x0C, + 0x28: 0x28, 0x29: 0x29, 0x5C: 0x5C} + + +def _decode_pdf_name(raw: bytes) -> bytes: + """Decode #XX escapes in a PDF name token to its canonical bytes.""" + if b"#" not in raw: + return raw + out = bytearray() + index_value = 0 + while index_value < len(raw): + if raw[index_value] == 0x23 and index_value + 2 < len(raw): + try: + out.append(int(raw[index_value + 1:index_value + 3], 16)) + index_value += 3 + continue + except ValueError: + pass + out.append(raw[index_value]) + index_value += 1 + return bytes(out) diff --git a/pageindex/flash/parser_pdfium_charlevel/pipeline.py b/pageindex/flash/parser_pdfium_charlevel/pipeline.py new file mode 100644 index 000000000..0d26ac455 --- /dev/null +++ b/pageindex/flash/parser_pdfium_charlevel/pipeline.py @@ -0,0 +1,268 @@ +"""Whole-document parse drivers assembling per-page charlevel metadata.""" + +from __future__ import annotations + +from io import BytesIO +from pathlib import Path +from typing import Union + +import pypdfium2 as pdfium + +# Raw PDF object access (ToUnicode CMaps, content streams, font dicts, /WMode) +# that PDFium does not expose, read via PyPDF2 -- already a project dependency and +# permissively licensed. A thin adapter exposes the small raw-object API the +# helpers below need, so their calibrated logic stays unchanged. +import PyPDF2 as _pypdf2 # declared dependency (also imported by pageindex.utils/client) + +from ..model import Span, Rect + +from .pdf_objects import _PdfDoc +from .text_normalize import ( + _DROP_CHARS, + _NORMALIZED_UNICODES, + _apply_bidi_reordering, + _reverse_if_rtl, +) +from .content_stream import ( + _tokenize_show_operators, + _assign_vertical_tags, + _assign_show_tz, + _page_vertical_resource_names, +) +from .cmap_parse import _compute_skew +from .code_walk import _page_show_codes +from .unicode_apply import _apply_font_unicode +from .char_extract import ( + _extract_raw_chars, + _accumulate_type3_extents, + _type3_size_by_font, + _apply_type3_sizes, + _finalize_chars, + _inherited_box, + _page_view_rect, +) +from .merge import _merge_text_items +from .remerge import ( + _remerge_rotated, + _remerge_oblique, + _remerge_vertical, +) + + +def parse_charlevel_meta(doc_handle: Union[str, Path, BytesIO]) -> tuple[list[list[Span]], list]: + if isinstance(doc_handle, (str, Path)): + pdf = pdfium.PdfDocument(str(doc_handle)) + elif isinstance(doc_handle, BytesIO): + pdf = pdfium.PdfDocument(doc_handle) + else: + pdf = doc_handle + + # Open the same document in PyPDF2 (already a project dependency) to read the + # page content streams: span merger item-flush operators (q/Q save/restore, marked + # content, XObject) live there and PDFium's flattened object model cannot expose + # them. Optional/guarded -- any failure leaves flush_id unset so the merger + # keeps its per-object split (the fallback behavior). A separate bytes copy + # avoids racing pypdfium2's read of the same BytesIO. + pdf_doc = None + if _pypdf2 is not None: + try: + if isinstance(doc_handle, (str, Path)): + pdf_doc = _PdfDoc(_pypdf2.PdfReader(str(doc_handle))) + elif isinstance(doc_handle, BytesIO): + # Read a copy so we never race pdfium's read of the same buffer. + pdf_doc = _PdfDoc(_pypdf2.PdfReader(BytesIO(doc_handle.getvalue()))) + except Exception: + pdf_doc = None + + # Pass 1: extract raw chars for every page (including each glyph's raw + # advance) and accumulate per-font identity-matrix Type-3 glyph-bbox + # extents document-wide, so each Type-3 font is sized once over every + # glyph it renders anywhere (coverage-independent), matching span merger + # synthesizing font.bbox once from the CharProcs. Font handles are only + # stable per document while their pages stay open (see keep_pages below). + per_page: list[list[dict]] = [] + page_view_boxes: list = [] # parallel to per_page: text extraction page view box per page + page_rotations: list = [] # parallel: PDFium page /Rotate in degrees per page + type3_ext: dict = {} + font_map_cache: dict = {} + # Hold every page open until pass 2's Type-3 size lookups are done. + # type3_ext / size_by_font key on the raw FPDF_FONT pointer VALUE, and + # PDFium frees a font once the last page using it closes -- a later + # page's (different) font can then be allocated at the same address, + # silently merging two fonts' extent bins. Which addresses get reused + # depends on the process's prior malloc state, so the output could vary + # with whatever ran earlier in the process. Keeping the pages alive makes + # the handle a true per-document + # font identity (PDFium's document-level font cache returns one handle + # per font redefinition). + keep_pages = [] + for page_idx in range(len(pdf)): + page = pdf[page_idx] + keep_pages.append(page) + text_page = page.get_textpage() + raw_chars, objects = _extract_raw_chars(page, text_page.raw) + try: + media_box_raw = _inherited_box(pdf_doc, page_idx, "MediaBox") if pdf_doc is not None else None + crop_box_raw = _inherited_box(pdf_doc, page_idx, "CropBox") if pdf_doc is not None else None + page_vb = _page_view_rect(page, media_box_raw, crop_box_raw) # (x0, y0, x1, y1) page space + except Exception: + page_vb = None # no box -> off-page test disabled + try: + page_rot = int(page.get_rotation()) # PDFium /Rotate (0/90/180/270) + except Exception: + page_rot = 0 + show_fonts: list[bytes | None] = [] + show_tzs: list[float] = [] + vert_names: set[bytes] = set() + if pdf_doc is not None and page_idx < pdf_doc.page_count: + try: + # show-op flush ids (q/Q flush scope) are no longer used -- the merge-id + # grouping was removed; only show_fonts (per-op font resname) + # feeds vertical tagging. + show_flush_ids, show_fonts, show_text_units, show_tzs, xobject_paints = _tokenize_show_operators( + pdf_doc[page_idx].read_contents()) + except Exception: + show_fonts = [] + show_tzs = [] + vert_names = _page_vertical_resource_names(pdf_doc, page_idx) + # Patch per-char unicode to span merger glyph Unicode where PDFium's + # decode differs (guarded: any failure keeps PDFium's output). + if raw_chars: + try: + show_codes = _page_show_codes(pdf_doc, page_idx) + if show_codes: + _apply_font_unicode( + text_page.raw, raw_chars, objects, show_codes, pdf_doc, + font_map_cache) + except Exception: + pass + _assign_vertical_tags(objects, show_fonts, vert_names) + _assign_show_tz(objects, show_tzs) + _accumulate_type3_extents(raw_chars, type3_ext) + per_page.append(raw_chars) + page_view_boxes.append(page_vb) + page_rotations.append(page_rot) + text_page.close() + if pdf_doc is not None and pdf_doc is not doc_handle: + try: + pdf_doc.close() + except Exception: + pass + size_by_font = _type3_size_by_font(type3_ext) + + # Pass 2: apply the document-wide Type-3 sizes, finalize glyph widths, + # then run text extraction text merger. + raw_pages: list[list[dict]] = [] + for page_view_index, raw_chars in enumerate(per_page): + _apply_type3_sizes(raw_chars, size_by_font) + # text extraction emits glyphs in CONTENT-STREAM (paint) order; PDFium's textpage + # reorders whole segments page-wide (math-heavy page margin labels 'margin label' / + # 'Section N' arrive at a different point of the char stream than their + # show ops). obj["page_order"] is the object's stream position (objects + # parse sequentially, incl. the Form XObject walk), so sorting real + # glyphs by it restores span merger processing order for the merger. + # GENERATED chars (PDFium's synthetic layout whitespace -- no span merger + # counterpart, pure merger bookkeeping) keep no position of their own: + # their geometric obj lookup can land on the WRONG object (the + # multi-column "4 | Super | vision" heading puts the '4'->'S' gap + # space inside the 'vision' object, which would re-emit it mid-word as + # "Super vision"), so each one stays glued behind the real glyph that + # precedes it in textpage order. Character-level ordering's + # own items on the reordered pages. + keys: list[tuple] = [()] * len(raw_chars) + last_key = None + lead_gens: list[int] = [] + for key_value, candidate_item in enumerate(raw_chars): + if candidate_item["is_gen"]: + if last_key is None: + lead_gens.append(key_value) + else: + keys[key_value] = (last_key[0], last_key[1], 1, key_value) + else: + last_key = (candidate_item["obj"]["page_order"], candidate_item["i"]) + keys[key_value] = (last_key[0], last_key[1], 0, key_value) + for key_value in lead_gens: + keys[key_value] = (-1, -1, 1, key_value) + raw_chars[:] = [raw_chars[key_value] for key_value in sorted(range(len(raw_chars)), + key=keys.__getitem__)] + fin = _finalize_chars(raw_chars) + merged = _merge_text_items(fin, page_view_boxes[page_view_index]) + merged = _remerge_rotated(merged) # collapse cardinal-rotated per-glyph shards + merged = _remerge_vertical(merged) # collapse vertical-writing per-glyph shards + merged = _remerge_oblique(merged, fin) # oblique objects: inverse-rotation projection re-merge + raw_pages.append(merged) + for page_handle in keep_pages: + try: + page_handle.close() + except Exception: + pass + keep_pages.clear() + + out: list[list[Span]] = [] + for raw in raw_pages: + spans: list[Span] = [] + for item in raw: + # the heading heuristics pushes normalized glyph Unicode = the normalized-Unicode table[u] or u + + # per glyph, a WHOLE-string lookup. Each r["str"] piece is one glyph's + # unicode (or a synthesized space), so look up per piece -- a + # multi-codepoint ToUnicode value is left intact when the whole-string + # lookup misses, instead of decomposing a table-key char inside it. + # span merger: normalized glyph Unicode = RTL ligature reversal(the normalized-Unicode table + # [u] or u) -- the table lookup is then wrapped in RTL ligature reversal, which + + # reverses a multi-char Arabic/Hebrew ligature value (span merger + #). Apply per piece (each r["str"] piece is one glyph's unicode). + joined = "".join( + _reverse_if_rtl(_NORMALIZED_UNICODES.get(page_value, page_value)) for page_value in item["str"] # type: ignore[arg-type] + ) + # text extraction text-item flush -> bidirectional transform: the joined item + # text runs the bidi pass ON TOP of the per-glyph RTL ligature reversal + # above (both layers exist in span merger). Pass-through for LTR text + # and vertical items (dir 'ttb'). + joined = _apply_bidi_reordering(joined, -1, bool(item["obj"].get("vertical"))) + text = joined.translate(_DROP_CHARS) + if not text: + continue + # font_size = hypot(text matrix[2], text matrix[3]) + # taken once at the item's open glyph, i.e. the chunk's first-char + # fs. The merger breaks a chunk on any fs change (exact compare; + # see the font_key/fs guard above) and never lowers fs mid-chunk, so + # chunk["fs"] (set in open_chunk from the first char) is exactly + # that value. Emit it rather than the per-chunk minimum. + fs_emit = item["fs"] + spans.append( + Span( + bbox=Rect(item["left"], item["right"], item["top"], item["bottom"]), + text=text, + font_name_raw=item["font_name"], + font_size=fs_emit, + # the heading heuristics bold is name-regex only (the font-name bold regex, + # OR'd into the emitted span). span merger bold detector ignores the descriptor + # ForceBold flag and numeric weight, so we must NOT inject a + # weight-based bold here — that over-bolds Demi/Medium/bold math font + # faces (weight 665-675) text extraction treats as regular. + bold=False, + italic=False, + # Span skew score: P = (f[1]/f[0])² + (f[2]/f[3])² from the item + # transform (IEEE: cardinal rotation -> Inf, upright -> 0). + # The owning object's PDFium matrix has the same + # rotation/shear structure as span merger item transform. + # mtx0 = the FIRST glyph's object matrix (text extraction fixes the + # item transform at open); standalone fake-space items + # carry no mtx0 and fall back to their obj (= the previous + # glyph's object == text extraction previous glyph transform for that space). + skew=_compute_skew(item.get("mtx0") or item["obj"]["mtx"]), + ) + ) + out.append(spans) + pdf.close() + # Per-page viewport metadata (text extraction normalized page view = cropbox clamped to the + # mediabox, via _page_view_rect, + /Rotate) parallel to out, so heading + # coordinates can apply span merger viewport-coordinate transform. + return out, list(zip(page_view_boxes, page_rotations)) + + +def parse_charlevel(doc_handle: Union[str, Path, BytesIO]) -> list[list[Span]]: + """Per-page span entry: per-page spans only (drops viewport meta). the high-level TOC pipeline uses ``parse_charlevel_meta`` to also get the per-page (view box, /Rotate) for heading coordinates; every other caller just wants the spans. """ + return parse_charlevel_meta(doc_handle)[0] diff --git a/pageindex/flash/parser_pdfium_charlevel/remerge.py b/pageindex/flash/parser_pdfium_charlevel/remerge.py new file mode 100644 index 000000000..8904bae58 --- /dev/null +++ b/pageindex/flash/parser_pdfium_charlevel/remerge.py @@ -0,0 +1,325 @@ +"""Re-merges rotated, oblique, and vertical spans after the first join pass.""" + +from __future__ import annotations + +import math + +from .text_normalize import ( + TRACKING_SPACE_FACTOR, + NEGATIVE_SPACE_FACTOR, + SPACE_IN_FLOW_MIN_FACTOR, + SPACE_IN_FLOW_MAX_FACTOR, +) + + +def _start_rot_span(chunk: dict) -> dict: + """A fresh single-glyph rotated span = a deep-enough copy of the merger chunk (keeps fs/font/obj/sign so the downstream span conversion is unchanged).""" + span = dict(chunk) + span["str"] = list(chunk["str"]) + span["font_tally"] = dict(chunk.get("font_tally", {})) + span["weight_tally"] = dict(chunk.get("weight_tally", {})) + return span + + +def _grow_rot_span(cur: dict, chunk: dict) -> None: + """Extend a rotated span with the next glyph: append text, union the page box (left/right/top/bottom stay in page coords -> output box is exact), merge the per-char style tallies.""" + cur["str"].extend(chunk["str"]) + cur["left"] = min(cur["left"], chunk["left"]) + cur["right"] = max(cur["right"], chunk["right"]) + cur["top"] = max(cur["top"], chunk["top"]) + cur["bottom"] = min(cur["bottom"], chunk["bottom"]) + for span, count in chunk.get("font_tally", {}).items(): + cur["font_tally"][span] = cur["font_tally"].get(span, 0) + count + for span, count in chunk.get("weight_tally", {}).items(): + cur["weight_tally"][span] = cur["weight_tally"].get(span, 0) + count + + +def _merge_rotated_one(group: list[dict], rot: int) -> list[dict]: + """1-D position comparison along the rotation axis for one cardinally rotated text object. ``read_origin`` is the glyph origin in reading order (90 reads up +y, 270 down -y, 180 left -x); the pen advances by glyph_w, so the inter-glyph gap is ``next_origin - (cur_origin + glyph_w)``. In-flow gaps join, larger gaps start a new item, and the box remains the page-space AABB required by downstream layout. Cardinal rotation intentionally does less than the oblique path: its box convention cannot match the oblique item-box convention, and the extra out-of-flow/cross-axis branches are not useful for these short rotated labels.""" + def read_origin(chunk: dict) -> float: + if rot == 90: + return chunk["bottom"] + if rot == 270: + return -chunk["top"] + if rot == 180: + return -chunk["right"] + return chunk["left"] + + ordered = sorted(group, key=read_origin) + spans: list[dict] = [] + cur: dict | None = None + pen = 0.0 + for chunk in ordered: + font_size = chunk.get("fs", 0.0) or 0.0 + glyph_width = chunk.get("glyph_w", 0.0) or 0.0 + origin = read_origin(chunk) + if cur is None: + cur = _start_rot_span(chunk) + pen = origin + glyph_width + continue + gap = origin - pen + if gap <= font_size * SPACE_IN_FLOW_MAX_FACTOR: + if gap > font_size * TRACKING_SPACE_FACTOR: + cur["str"].append(" ") + _grow_rot_span(cur, chunk) + else: + spans.append(cur) + cur = _start_rot_span(chunk) + pen = origin + glyph_width + if cur is not None: + spans.append(cur) + return spans + + +def _remerge_rotated(items: list[dict]) -> list[dict]: + """Re-merge the per-glyph chunks of each rotated text object into text items along the rotation axis. Upright text is untouched; merged spans keep the first chunk position for reading order.""" + rot_groups: dict[int, list[dict]] = {} + for item in items: + obj = item.get("obj") + if isinstance(obj, dict) and obj.get("rot") in (90, 180, 270): + rot_groups.setdefault(id(obj), []).append(item) + if not rot_groups: + return items + + merged_for = { + oid: _merge_rotated_one(group, group[0]["obj"]["rot"]) + for oid, group in rot_groups.items() + } + out: list[dict] = [] + emitted: set[int] = set() + for item in items: + obj = item.get("obj") + if isinstance(obj, dict) and obj.get("rot") in (90, 180, 270): + oid = id(obj) + if oid not in emitted: + emitted.add(oid) + out.extend(merged_for[oid]) + else: + out.append(item) + return out + + +def _new_oblique_span(glyph: dict, baseline_pos: float, cross_pos: float, glyph_width: float) -> dict: + """Open an oblique item at its first reading-order glyph. Records the glyph's page-space pen origin, along-baseline start, cross-axis position, and running pen so the gap logic can compare the next glyph.""" + return { + "str": [glyph["ch"]], + "obj": glyph["obj"], + "fs": glyph["fs"], + "font_name": glyph["font_name"], + "_ox0": glyph["ox"], "_oy0": glyph["oy"], + "_u0": baseline_pos, "_uend": baseline_pos + glyph_width, "_pen": baseline_pos + glyph_width, "_vlast": cross_pos, + "_lox": glyph["ox"], "_loy": glyph["oy"], "_lgw": glyph_width, + } + + +def _close_oblique(cur: dict) -> dict: + """Finalize an oblique item's box. The item merger is rotation-agnostic -- it turns ANY text extraction item into a span via left=transform[4], right=+width, bottom=transform[5], top=+height -- so an oblique item's box is upright at its pen origin, with width = the along-baseline advance (text extraction item.width, NOT the diagonal x-extent the horizontal merger would compute) and height = font size.""" + width = cur["_uend"] - cur["_u0"] + cur["left"] = cur["_ox0"] + cur["right"] = cur["_ox0"] + width + cur["bottom"] = cur["_oy0"] + cur["top"] = cur["_oy0"] + cur["fs"] + return cur + + +def _oblique_space(cur: dict, adv: float, baseline_unit_x: float, baseline_unit_y: float, scale: float) -> dict: + """span merger ``synthetic-space insertion`` out-of-flow item: a STANDALONE " " at the previous glyph's pen (previous glyph transform), width=|advance-x|, height 0 (horizontal). The pen sits at the last glyph's origin advanced by its width along the baseline unit direction ``(ux,uy)``. span merger ``advance-x`` is ``(posX-lastPosX)/text advance scale``, so the width is normalised by the matrix scale (== text advance scale here); on identity CTM scale==1 so this is a no-op, but under a scaled CTM it matters. Output box = left=pen_x, right=+width, bottom=top=pen_y.""" + pen_x = cur["_lox"] + cur["_lgw"] * baseline_unit_x + pen_y = cur["_loy"] + cur["_lgw"] * baseline_unit_y + width_value = abs(adv) / scale + return { + "str": [" "], "obj": cur["obj"], "fs": cur["fs"], "font_name": cur["font_name"], + "left": pen_x, "right": pen_x + width_value, "bottom": pen_y, "top": pen_y, + } + + +def _merge_oblique_one(chs: list[dict]) -> list[dict]: + """text extraction position comparison (inverse-rotation projection path) for ONE oblique text object's glyphs -- the explicit horizontal-branch implementation. ``inverse-rotation projection(x,y,m) = [(m0*x+m1*y)/s, (m2*x+m3*y)/s]`` (s=hypot(m0,m1)); component 0 is the reading-order (baseline) coordinate, component 1 the cross axis. Projecting each glyph's pen origin onto these gives advance-x (along, the gap beyond the prev glyph's advance) and advance-y (cross). Then apply the item split thresholds: advance-xheight -> split; advance-x<=tracking-space threshold -> join no space; <=in-flow space threshold -> in-flow space in str; else synthetic-space insertion -> a STANDALONE " " item then split. Items carry the item-box convention box (see _close_oblique).""" + matrix_a, matrix_b, matrix_c, matrix_d = chs[0]["obj"]["mtx"] + scale = math.hypot(matrix_a, matrix_b) or 1.0 + baseline_unit_x, baseline_unit_y = matrix_a / scale, matrix_b / scale # baseline unit direction (page space) + + def along(glyph: dict) -> float: + return (matrix_a * glyph["ox"] + matrix_b * glyph["oy"]) / scale + + def cross(glyph: dict) -> float: + return (matrix_c * glyph["ox"] + matrix_d * glyph["oy"]) / scale + + ordered = sorted(chs, key=along) + spans: list[dict] = [] + cur: dict | None = None + for glyph in ordered: + if glyph.get("is_ws"): + # Skip whitespace glyphs entirely (== main span merger skips whitespace, + # no pen update): text extraction never pushes a raw space glyph to str; the gap + # they leave is re-synthesised by the in-flow/out-of-flow logic below + # for the next visible glyph. This collapses runs of spaces to one and + # trims trailing/leading spaces using the last-character buffer. + continue + font_size = glyph.get("fs", 0.0) or 0.0 + glyph_width = glyph.get("glyph_w", 0.0) or 0.0 + baseline_pos = along(glyph) + cross_pos = cross(glyph) + if cur is None: + cur = _new_oblique_span(glyph, baseline_pos, cross_pos, glyph_width) + continue + baseline_gap = baseline_pos - cur["_pen"] # along-baseline gap beyond prev advance + cross_shift = cross_pos - cur["_vlast"] # cross-axis shift + if baseline_gap < font_size * NEGATIVE_SPACE_FACTOR or abs(cross_shift) > font_size: + # back-jump (backward-jump threshold) or cross-axis line break: span merger + # flush/line-break emission -- either way the item merger just starts a new item. + spans.append(_close_oblique(cur)) + cur = _new_oblique_span(glyph, baseline_pos, cross_pos, glyph_width) + continue + if baseline_gap <= font_size * TRACKING_SPACE_FACTOR: + cur["str"].append(glyph["ch"]) # join, no space + elif baseline_gap <= font_size * SPACE_IN_FLOW_MAX_FACTOR: + cur["str"].append(" ") # in-flow space + cur["str"].append(glyph["ch"]) + else: + spans.append(_close_oblique(cur)) # out-of-flow: + spans.append(_oblique_space(cur, baseline_gap, baseline_unit_x, baseline_unit_y, scale)) # standalone " " + cur = _new_oblique_span(glyph, baseline_pos, cross_pos, glyph_width) + continue + cur["_uend"] = baseline_pos + glyph_width + cur["_pen"] = baseline_pos + glyph_width + cur["_vlast"] = cross_pos + cur["_lox"], cur["_loy"], cur["_lgw"] = glyph["ox"], glyph["oy"], glyph_width + if cur is not None: + spans.append(_close_oblique(cur)) + return spans + + +def _remerge_oblique(items: list[dict], fin_chars: list[dict]) -> list[dict]: + """Rebuild oblique text objects by re-merging per-glyph chunks along the baseline and emitting item-box-convention boxes. Upright and cardinal text are untouched.""" + groups: dict[int, list[dict]] = {} + for glyph in fin_chars: + obj = glyph.get("obj") + if isinstance(obj, dict) and obj.get("rot") == -1: + groups.setdefault(id(obj), []).append(glyph) + if not groups: + return items + + merged_for = {oid: _merge_oblique_one(chs) for oid, chs in groups.items()} + out: list[dict] = [] + emitted: set[int] = set() + for item in items: + obj = item.get("obj") + if isinstance(obj, dict) and obj.get("rot") == -1: + oid = id(obj) + if oid not in emitted: + emitted.add(oid) + out.extend(merged_for[oid]) + else: + out.append(item) + return out + + +def _start_vert_span(chunk: dict) -> dict: + """Create a vertical item from its first chunk. Vertical items use the rendered font size as width, accumulate height per glyph, and keep the first glyph's pen as the item transform. The span box therefore extends upward from the first pen even when glyphs visually run downward; this follows the same item-box convention used by horizontal and oblique items rather than the ink AABB.""" + span = dict(chunk) + span["str"] = list(chunk["str"]) + span["font_tally"] = dict(chunk.get("font_tally", {})) + span["weight_tally"] = dict(chunk.get("weight_tally", {})) + span["v_height"] = chunk["v_pen_y"] - chunk["v_after"] # first glyph's advance + return span + + +def _close_vert_span(mapping: dict) -> dict: + """Finalize the item merger-convention box of a vertical item.""" + mapping["left"] = mapping["v_pen_x"] + mapping["right"] = mapping["v_pen_x"] + mapping["fs"] + mapping["bottom"] = mapping["v_pen_y"] + mapping["top"] = mapping["v_pen_y"] + abs(mapping["v_height"]) + return mapping + + +def _merge_vertical_one(group: list[dict]) -> list[dict]: + """Apply vertical-writing position comparison over one text object's per-glyph chunks in stream order. The previous pen-after-advance and current pen define the along-axis gap; x shift is the cross-axis break signal. Small gaps join, in-flow gaps insert a space, out-of-flow gaps emit a standalone zero-width space item, and backward or cross-axis jumps start a new item. Whitespace glyphs are consumed by the span merger, so their advance arrives here as an in-flow gap.""" + spans: list[dict] = [] + cur: dict | None = None + after = 0.0 # text extraction previous glyph transform[5]: pen y after the previous glyph + last_x = 0.0 # text extraction previous glyph transform[4] + for chunk in group: + font_size = chunk.get("fs", 0.0) or 0.0 + if cur is None: + cur = _start_vert_span(chunk) + after, last_x = chunk["v_after"], chunk["v_pen_x"] + continue + vertical_gap = after - chunk["v_pen_y"] + x_shift = chunk["v_pen_x"] - last_x + direction_sign = 1.0 if cur["v_height"] >= 0 else -1.0 + width = cur["fs"] + if vertical_gap < direction_sign * NEGATIVE_SPACE_FACTOR * font_size or abs(x_shift) > width: + # backward jump or cross-axis break: text extraction line-break emission/flush -- both + # end the item (we don't model line-break marker, and the item merger ignores it). + spans.append(_close_vert_span(cur)) + cur = _start_vert_span(chunk) + elif vertical_gap <= direction_sign * TRACKING_SPACE_FACTOR * font_size: + cur["v_height"] += vertical_gap + (chunk["v_pen_y"] - chunk["v_after"]) + _grow_vert_span(cur, chunk) + elif direction_sign * SPACE_IN_FLOW_MIN_FACTOR * font_size <= vertical_gap <= direction_sign * SPACE_IN_FLOW_MAX_FACTOR * font_size: + cur["str"].append(" ") + cur["v_height"] += vertical_gap + (chunk["v_pen_y"] - chunk["v_after"]) + _grow_vert_span(cur, chunk) + else: + # out-of-flow: standalone " " at previous glyph transform, width 0, height |e| + # (vertical synthetic spaces store the gap as height and leave width at zero). + meta = cur + spans.append(_close_vert_span(cur)) + spans.append({ + "str": [" "], "sign": 1, "obj": meta["obj"], + "left": last_x, "right": last_x, # WIDTH 0 + "bottom": after, "top": after + abs(vertical_gap), + "fs": meta["fs"], "fs_min": meta["fs"], + "font_name": meta["font_name"], "font_key": meta["font_key"], + "weight": meta["weight"], + "font_tally": {meta["font_name"]: 1}, + "weight_tally": {meta["weight"]: 1}, + }) + cur = _start_vert_span(chunk) + after, last_x = chunk["v_after"], chunk["v_pen_x"] + if cur is not None: + spans.append(_close_vert_span(cur)) + return spans + + +def _grow_vert_span(cur: dict, chunk: dict) -> None: + """Append a glyph to a vertical item: text + style tallies. The box is NOT unioned here -- it is derived from the first pen + accumulated v_height in _close_vert_span, with transform fixed at the first glyph and height accumulated.""" + cur["str"].extend(chunk["str"]) + for span, count in chunk.get("font_tally", {}).items(): + cur["font_tally"][span] = cur["font_tally"].get(span, 0) + count + for span, count in chunk.get("weight_tally", {}).items(): + cur["weight_tally"][span] = cur["weight_tally"].get(span, 0) + count + + +def _remerge_vertical(items: list[dict]) -> list[dict]: + """Re-merge the per-glyph chunks of each vertical-writing (Identity-V / WMode 1) text object into PDF content tokenizer style items. Uses the same _remerge_rotated: the horizontal merger is untouched (it shatters a vertical column because the glyphs stack along its line-break axis) and this gated post-pass rewrites only vertical-object chunks. One extra wrinkle vs the rotated pass: text extraction emits items in content-stream order, but PDFium's textpage reorders vertical chars page-wide (its own column heuristic), so the merged groups are reassigned to the vertical slot positions in object paint order.""" + groups: dict[int, list[dict]] = {} + obj_of: dict[int, dict] = {} + for item in items: + obj = item.get("obj") + if (isinstance(obj, dict) and obj.get("vertical") and not obj.get("rot") + and "v_pen_y" in item): + oid = id(obj) + groups.setdefault(oid, []).append(item) + obj_of[oid] = obj + if not groups: + return items + + merged_for = {oid: _merge_vertical_one(group_value) for oid, group_value in groups.items()} + paint_order = sorted(groups, key=lambda oid: obj_of[oid]["page_order"]) + out: list[dict] = [] + slot = 0 # next paint-order group to emit at the next vertical slot + seen: set[int] = set() + for item in items: + obj = item.get("obj") + oid = id(obj) if isinstance(obj, dict) else None + if oid in groups: + if oid not in seen: + seen.add(oid) + out.extend(merged_for[paint_order[slot]]) + slot += 1 + else: + out.append(item) + return out diff --git a/pageindex/flash/parser_pdfium_charlevel/text_normalize.py b/pageindex/flash/parser_pdfium_charlevel/text_normalize.py new file mode 100644 index 000000000..9c477e3f5 --- /dev/null +++ b/pageindex/flash/parser_pdfium_charlevel/text_normalize.py @@ -0,0 +1,288 @@ +"""Unicode normalization tables, whitespace classes, spacing factors, and bidi reordering.""" + +from __future__ import annotations + +import json +import unicodedata +from pathlib import Path + + +_DROP_CHARS = str.maketrans({ + # U+FFFE is PDFium's "no unicode mapping" textpage sentinel. The + # patch pipeline (_apply_font_unicode) replaces it with decoded text + # wherever the map walk succeeds; a REMAINING U+FFFE means the guarded + # walk gave up for that run, so deleting it keeps PDFium noise out of the + # spans. A pathological ToUnicode map that intentionally emits literal + # U+FFFE is indistinguishable from this sentinel here and is dropped. + "￾": None, + "\t": " ", + "\n": " ", + "\r": " ", + # text extraction maps a glyph whose unicode lands on U+00AD to U+002D. (An + # earlier "\x02" -> "-" entry here compensated PDFium decoding + # re-encoded hyphens (charcode 2, ToUnicode gap) as U+0002; that decode + # is now handled by _apply_font_unicode: mapped soft hyphens emit '-' where the + # font's Differences name the glyph, and keeps the raw \x02 where they + # don't, e.g. math-heavy page body ligature codes. + "­": "-", +}) + + +# The normalized Unicode table is a fixed, sparse per-glyph +# lookup table; a char absent from it is emitted unchanged. This is NOT Unicode +# NFKC: NFKC over-normalises (fullwidth→ASCII, superscripts→digits, ohm→omega, +# nbsp→space) exactly where this table leaves the glyph untouched. Apply the +# table per code point. +_NORMALIZED_UNICODES: dict[str, str] = json.loads( + (Path(__file__).parent.parent / "data" / "normalized_unicodes.json") + .read_text(encoding="utf-8") +) + + +def _normalize_unicodes(text: str) -> str: + """Apply the per-glyph normalized-Unicode substitution table to a text item. The table is keyed by single code points and never introduces table keys, so applying it to the already-joined LTR item string preserves per-glyph substitution after the text item is joined.""" + unit_count = _NORMALIZED_UNICODES + if not any(candidate_item in unit_count for candidate_item in text): + return text + return "".join(unit_count.get(candidate_item, candidate_item) for candidate_item in text) + + +# span merger +TRACKING_SPACE_FACTOR = 0.1 +NON_SPACE_GAP_FACTOR = 0.03 +NEGATIVE_SPACE_FACTOR = -0.2 +SPACE_IN_FLOW_MIN_FACTOR = 0.1 +SPACE_IN_FLOW_MAX_FACTOR = 0.6 + + +# Whitespace classification uses the Unicode WhiteSpace + LineTerminator set. +# Python's str.isspace is not the same set: it omits U+FEFF and adds +# U+001C-U+001F and U+0085. Use the explicit code points so the +# whitespace-skip branch fires on the intended glyphs. +_WHITESPACE_CODEPOINTS = frozenset({ + 0x9, 0xA, 0xB, 0xC, 0xD, 0x20, 0xA0, 0x1680, + 0x2000, 0x2001, 0x2002, 0x2003, 0x2004, 0x2005, 0x2006, 0x2007, + 0x2008, 0x2009, 0x200A, 0x2028, 0x2029, 0x202F, 0x205F, 0x3000, + 0xFEFF, +}) + + +def _is_whitespace(number: int) -> bool: + """Return whether a glyph code point is classified as whitespace.""" + return number in _WHITESPACE_CODEPOINTS + + +# Character classification checks whitespace before marks/formats, so a code +# point such as U+FEFF that is also Cf is treated as whitespace, not as an +# invisible format mark. +def _is_zero_width_diacritic(number: int) -> bool: + """text extraction zero-width diacritic classification (group 2 = ``\\p{Mn}``).""" + return number not in _WHITESPACE_CODEPOINTS and unicodedata.category(chr(number)) == "Mn" + + +def _is_invisible_format_mark(number: int) -> bool: + """text extraction invisible format-mark classification (group 3 = ``\\p{Cf}``).""" + return number not in _WHITESPACE_CODEPOINTS and unicodedata.category(chr(number)) == "Cf" + + +# Bidirectional character-type tables. base bidi type table covers +# U+0000..U+00FF; Arabic bidi type table covers U+0600..U+06FF indexed by the low byte +# (the "" at 0x1D follows the extraction rule placeholder for nonexistent U+061D). + +_BIDI_BASE_TYPES = ( + "BN BN BN BN BN BN BN BN BN S B S WS B BN BN BN BN BN BN BN BN BN BN BN BN " + "BN BN B B B S WS ON ON ET ET ET ON ON ON ON ON ES CS ES CS CS EN EN EN EN " + "EN EN EN EN EN EN CS ON ON ON ON ON ON L L L L L L L L L L L L L L L L L L " + "L L L L L L L L ON ON ON ON ON ON L L L L L L L L L L L L L L L L L L L L " + "L L L L L L ON ON ON ON BN BN BN BN BN BN B BN BN BN BN BN BN BN BN BN BN " + "BN BN BN BN BN BN BN BN BN BN BN BN BN BN BN BN CS ON ET ET ET ET ON ON ON " + "ON L ON ON BN ON ON ET ET EN EN ON L ON ON ON EN L ON ON ON ON ON L L L L " + "L L L L L L L L L L L L L L L L L L L ON L L L L L L L L L L L L L L L L L " + "L L L L L L L L L L L L L L ON L L L L L L L L " +).split() +assert len(_BIDI_BASE_TYPES) == 256 +_BIDI_ARABIC_TYPES = [ + "" if bidi_type == "~" else bidi_type for bidi_type in ( + "AN AN AN AN AN AN ON ON AL ET ET AL CS AL ON ON NSM NSM NSM NSM NSM NSM " + "NSM NSM NSM NSM NSM AL AL ~ AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL " + "AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL " + "AL AL AL AL AL NSM NSM NSM NSM NSM NSM NSM NSM NSM NSM NSM NSM NSM NSM NSM " + "NSM NSM NSM NSM NSM NSM AN AN AN AN AN AN AN AN AN AN ET AN AN AL AL AL " + "NSM AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL " + "AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL " + "AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL " + "AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL AL " + "AL AL AL NSM NSM NSM NSM NSM NSM NSM AN ON NSM NSM NSM NSM NSM NSM AL AL " + "NSM NSM ON NSM NSM NSM NSM AL AL EN EN EN EN EN EN EN EN EN EN AL AL AL AL " + "AL AL " + ).split() +] +assert len(_BIDI_ARABIC_TYPES) == 256 + + +def _apply_bidi_reordering(text: str, start_level: int = -1, vertical: bool = False) -> str: + """Apply the simplified single-line UAX#9 pass used for flushed PDF text items. Empty, vertical, and purely LTR text pass through. Otherwise the pass resolves W1-W7/N1-N2/I1-I2 levels from the tables above, reverses runs, and strips literal '<'/'>'. Astral-codepoint handling follows Python strings; surrogate pairs are not corrupted because both halves classify L at equal levels and reversal spans restore the pair.""" + if not text or vertical: + return text + count_item = len(text) + chars = list(text) + types: list[str] = [""] * count_item + num_bidi = 0 + for index_value, char in enumerate(chars): + codepoint = ord(char) + token_value = "L" + if codepoint <= 0xFF: + token_value = _BIDI_BASE_TYPES[codepoint] + elif 0x0590 <= codepoint <= 0x05F4: + token_value = "R" + elif 0x0600 <= codepoint <= 0x06FF: + token_value = _BIDI_ARABIC_TYPES[codepoint & 0xFF] + elif 0x0700 <= codepoint <= 0x08AC: + token_value = "AL" + if token_value in ("R", "AL", "AN"): + num_bidi += 1 + types[index_value] = token_value + if num_bidi == 0: + return text + if start_level == -1: + if num_bidi / count_item < 0.3 and count_item > 4: + start_level = 0 + else: + start_level = 1 + levels = [start_level] * count_item + entry_item = "R" if (start_level & 1) else "L" + sor = entry_item + eor = sor + # W1: NSM takes the type of the previous character (sor at run start). + last = sor + for index_value in range(count_item): + if types[index_value] == "NSM": + types[index_value] = last + else: + last = types[index_value] + # W2: EN after an AL (searching back to the first strong type) becomes AN. + last = sor + for index_value in range(count_item): + token_value = types[index_value] + if token_value == "EN": + types[index_value] = "AN" if last == "AL" else "EN" + elif token_value in ("R", "L", "AL"): + last = token_value + # W3: AL -> R. + for index_value in range(count_item): + if types[index_value] == "AL": + types[index_value] = "R" + # W4: single ES between ENs -> EN; single CS between same-type numbers. + for index_value in range(1, count_item - 1): + if types[index_value] == "ES" and types[index_value - 1] == "EN" and types[index_value + 1] == "EN": + types[index_value] = "EN" + if (types[index_value] == "CS" and types[index_value - 1] in ("EN", "AN") + and types[index_value + 1] == types[index_value - 1]): + types[index_value] = types[index_value - 1] + # W5: ET runs adjacent to EN -> EN. + for index_value in range(count_item): + if types[index_value] == "EN": + for state_item in range(index_value - 1, -1, -1): + if types[state_item] != "ET": + break + types[state_item] = "EN" + for state_item in range(index_value + 1, count_item): + if types[state_item] != "ET": + break + types[state_item] = "EN" + # W6: WS/ES/ET/CS -> ON. + for index_value in range(count_item): + if types[index_value] in ("WS", "ES", "ET", "CS"): + types[index_value] = "ON" + # W7: EN after an L (searching back to the first strong type) -> L. + last = sor + for index_value in range(count_item): + token_value = types[index_value] + if token_value == "EN": + types[index_value] = "L" if last == "L" else "EN" + elif token_value in ("R", "L"): + last = token_value + # N1: neutrals between same-direction strongs take that direction + # (numbers count as R); N2: leftovers take the embedding direction. + index_value = 0 + while index_value < count_item: + if types[index_value] == "ON": + end = index_value + 1 + while end < count_item and types[end] == "ON": + end += 1 + before = types[index_value - 1] if index_value > 0 else sor + after = types[end + 1] if end + 1 < count_item else eor + if before != "L": + before = "R" + if after != "L": + after = "R" + if before == after: + for state_item in range(index_value, end): + types[state_item] = before + index_value = end - 1 + index_value += 1 + for index_value in range(count_item): + if types[index_value] == "ON": + types[index_value] = entry_item + # I1/I2: level bumps. + for index_value in range(count_item): + token_value = types[index_value] + if levels[index_value] % 2 == 0: + if token_value == "R": + levels[index_value] += 1 + elif token_value in ("AN", "EN"): + levels[index_value] += 2 + else: + if token_value in ("L", "AN", "EN"): + levels[index_value] += 1 + #: reverse contiguous runs from the highest level down to the lowest + # odd level. + highest = -1 + lowest_odd = 99 + for layout_value in levels: + if layout_value > highest: + highest = layout_value + if layout_value < lowest_odd and (layout_value & 1): + lowest_odd = layout_value + for level in range(highest, lowest_odd - 1, -1): + start = -1 + for index_value in range(count_item): + if levels[index_value] < level: + if start >= 0: + chars[start:index_value] = chars[start:index_value][::-1] + start = -1 + elif start < 0: + start = index_value + if start >= 0: + chars[start:count_item] = chars[start:count_item][::-1] + # text extraction final loop: literal '<' and '>' are dropped (numBidi > 0 only). + return "".join("" if char in "<>" else char for char in chars) + + +def _rtl_sign(char: str) -> int: + """+1 for LTR runs, -1 for a strong right-to-left char (bidi class R/AL, e.g. Hebrew/Arabic). PDFium reports RTL text in logical order with decreasing char origins, so the LTR ``advance = ox - prev_text_x`` model (prev_text_x = ox+glyph_w, a right edge) yields a large negative advance. For RTL chunks the x-axis is signed with ``sign*ox`` so the reading-direction advance is positive and the existing LTR merge logic applies unchanged.""" + return -1 if unicodedata.bidirectional(char) in ("R", "AL") else 1 + + +def _reverse_if_rtl(chars: str) -> str: + """span merger ``RTL ligature reversal`` : reverse a multi-char (Arabic/Hebrew ligature) value when its FIRST code unit is in the Hebrew ``[0x0590,0x05ff)`` or Arabic ``[0x0600,0x06ff)`` range (Unicode range table[11]/ [13], ``right-to-left range test`` uses ``>= begin and < end``, so the range end is EXCLUSIVE). text extraction wraps every glyph's ``normalized Unicode`` in this, so a table value like "\u0626\u062c" emitted for U+FC00 is reversed to "\u062c\u0626"; a single-char value (length <= 1) is returned as-is.""" + if len(chars) <= 1: + return chars + first_codepoint = ord(chars[0]) + if (0x0590 <= first_codepoint < 0x05FF) or (0x0600 <= first_codepoint < 0x06FF): + return chars[::-1] + return chars + + +def _read_end(mapping: dict, sign: int) -> float: + """The reading-direction FAR edge of a glyph (the edge facing the next char). PDFium reports the origin (ox) as the glyph's LEFT edge in both directions; the glyph extends RIGHT by glyph_w. So: * LTR (reading right): far edge = right edge = max(ox+glyph_w, ink right). * RTL (reading left): far edge = LEFT edge = ox (the origin itself). The next char's gap is then measured to its NEAR edge -- ox for LTR, ox+glyph_w for RTL -- in ``_read_gap`` below. (Earlier this added glyph_w on the RTL side too, which used the PREVIOUS glyph's width and injected spurious spaces.)""" + if sign > 0: + return max(mapping["ox"] + mapping["glyph_w"], mapping["right"]) + return mapping["ox"] + + +def _read_gap(prev_far: float, other_mapping: dict, sign: int) -> float: + """Reading-direction gap between the previous glyph's far edge and the current glyph's NEAR edge. LTR near edge = ox (left); RTL near edge = ox+glyph_w (right). ==0 for adjacent glyphs, >0 for a word gap, <0 for a backward jump.""" + if sign > 0: + return other_mapping["ox"] - prev_far + return prev_far - (other_mapping["ox"] + other_mapping["glyph_w"]) diff --git a/pageindex/flash/parser_pdfium_charlevel/unicode_apply.py b/pageindex/flash/parser_pdfium_charlevel/unicode_apply.py new file mode 100644 index 000000000..e6356c87f --- /dev/null +++ b/pageindex/flash/parser_pdfium_charlevel/unicode_apply.py @@ -0,0 +1,375 @@ +"""Applies per-font Unicode maps to page chars and synthesizes dropped glyphs.""" + +from __future__ import annotations + +import bisect +import difflib +from collections import Counter +import pypdfium2.raw as pdfium_c + +from .text_normalize import _is_whitespace +from .font_unicode import _font_unicode_map +from .code_walk import ( + _char_category, + _walk_codes, +) + + +def _apply_font_unicode( + text_page, + raw_chars: list[dict], + objects: list[dict], + show_codes: list[tuple[int | None, tuple[int, ...]]], + pdf_doc, + map_cache: dict, +) -> None: + """Patch each char's unicode to span merger glyph Unicode (`map.get(code) or chr(code)`, content stream tokenizer glyph mapping) where PDFium's decode disagrees. Two granularities, both gated by _walk_codes' both-streams-exhaust rule: - object mode (when PDFium's text objects pair consistent with the page's show ops, the _assign_flush_ids precondition): each object's chars are walked against its own show op's codes. This is immune to PDFium's textpage segment reordering (e.g. math-heavy page margin labels emitted at a different page position than paint order) because chars keep stream order WITHIN an object; a desync rolls back only that object. - page mode (counts differ, e.g. PDFium splitting a TJ into several objects): all non-generated textpage chars are walked against all show ops' codes in paint order; any desync rolls back the whole page. """ + if not show_codes: + return + + def targets_for(font_xref: int | None, other_numbers: tuple[int, ...]) -> list[str] | None: + if font_xref is None: + return None + if font_xref not in map_cache: + try: + map_cache[font_xref] = _font_unicode_map(pdf_doc, font_xref) + except Exception: + map_cache[font_xref] = None + entry = map_cache[font_xref] + if entry is None: + return None + next_block, measure_item = entry + if next_block == 1: + return [measure_item.get(code) or chr(code) for code in other_numbers] + return [measure_item.get((other_numbers[key_value] << 8) | other_numbers[key_value + 1]) or chr((other_numbers[key_value] << 8) | other_numbers[key_value + 1]) + for key_value in range(0, len(other_numbers) - 1, 2)] + + def apply(patches: list[tuple[int, str]], drops: list[int], + chars_by_index: dict[int, dict]) -> None: + for index_value, token_value in patches: + candidate_item = chars_by_index.get(index_value) + if candidate_item is None: + continue # char was dropped at extraction; nothing to patch + candidate_item["ch"] = token_value + candidate_item["is_ws"], candidate_item["is_mn"], candidate_item["is_cf"] = _char_category(token_value) + for index_value in drops: + candidate_item = chars_by_index.get(index_value) + if candidate_item is not None: + candidate_item["drop"] = True + + chars_by_index = {raw_char["i"]: raw_char for raw_char in raw_chars} + + if len(objects) == len(show_codes): + # Object mode: pair text objects with show ops ordinally (both are in + # content-stream paint order) and walk each pair independently. + chars_by_obj: dict[int, list[tuple[int, str]]] = {} + for raw_char in raw_chars: + if raw_char["is_gen"]: + continue + chars_by_obj.setdefault(id(raw_char["obj"]), []).append((raw_char["i"], raw_char["ch"])) + desynced: list[int] = [] + failed_windows: list[list[int]] = [] + synth_sites: list[dict] = [] + targets_by_object_index: dict[int, list[str] | None] = {} + for object_index, (obj, (font_index, encoded_text)) in enumerate(zip(objects, show_codes)): + target_text_items = targets_for(font_index, encoded_text) + targets_by_object_index[object_index] = target_text_items + if target_text_items is None: + continue # uncovered font: this object keeps PDFium's output + res = _walk_codes(chars_by_obj.get(id(obj), []), target_text_items) + if res is None: + # Desync: often a boundary-attribution error (the geometric + # char->object lookup parks a show op's edge glyph in the + # NEIGHBOURING object's list: punctuation at a run boundary can + # land in the previous object, and heavily overlapped chart + # labels can park a leading glyph in the wrong object. Record for the + # window re-walk below; a genuine mismatch stays rolled back + # there too. + desynced.append(object_index) + continue + apply(res[0], res[1], chars_by_index) + # Re-walk each window of desynced objects (bridging up to 2 covered, + # successfully-walked objects between them) as one unit: boundary- + # attribution errors cancel inside the window (the page-mode walk + # scoped to the ambiguous region) and the exhaust-in-sync gate still + # rejects anything else. On commit, REASSIGN each consumed char to + # the object whose show op consumed it -- the stream-side ownership -- + # repairing the geometric attribution for the paint-order sort, the + # merger's font/fs identity and the Type-3 sizing alike. + def _rewalk_window(window: list[int]) -> bool: + char_value = sorted( + (pair for state_item in window for pair in chars_by_obj.get(id(objects[state_item]), []))) + text_transform: list[str] = [] + owner: list[int] = [] + for state_item in window: + target_text_items = targets_by_object_index[state_item] + assert target_text_items is not None + text_transform.extend(target_text_items) + owner.extend([state_item] * len(target_text_items)) + def _commit(res) -> bool: + if res is None: + return False + apply(res[0], res[1], chars_by_index) + for char_index, text_index in res[2]: + candidate_item = chars_by_index.get(char_index) + if candidate_item is not None and candidate_item["obj"] is not objects[owner[text_index]]: + candidate_item["obj"] = objects[owner[text_index]] + # Skipped targets are glyphs PDFium never emitted; record + # each with its show op and surviving stream neighbours so + # _synthesize_dropped_glyphs can re-emit it (text extraction does). + for text_index, pos in res[3]: + synth_sites.append({ + "t": text_transform[text_index], "owner": objects[owner[text_index]], + "prev_i": char_value[pos - 1][0] if pos > 0 else None, + "next_i": char_value[pos][0] if pos < len(char_value) else None, + }) + return True + if len(window) >= 2 and _commit(_walk_codes(char_value, text_transform)): + return True + # Pure-displacement fallback: PDFium's textpage can also REORDER a + # char across the window (TeX accents again: 'accented word stem'+'´'+'es' + # arrives as '...ilites´', and the 't' sits in the 'es' object), + # which the linear walk above can never align. When the chars are + # EXACTLY the targets as a multiset (no decode work left -- only + # placement is wrong), align via SequenceMatcher and repair + # OWNERSHIP alone: equal blocks map positionally, the few + # displaced chars (<=4) map by literal value. Single-char targets + # only, so target index == string position. + def _displacement_repair() -> bool: + if any(len(token_value) != 1 for token_value in text_transform): + return False + chs = "".join(candidate_item for _, candidate_item in char_value) + tts = "".join(text_transform) + deficit = len(tts) - len(chs) + if (chs == tts or deficit < 0 or deficit > 8 + or (Counter(chs) - Counter(tts))): + return False + state_map = difflib.SequenceMatcher(None, tts, chs, autojunk=False) + char_to_tgt: dict[int, int] = {} + loose_target_indexes: list[int] = [] + loose_char_indexes: list[int] = [] + for tag, index_one, index_two, char_start, char_end in state_map.get_opcodes(): + if tag == "equal": + for reference_item in range(index_two - index_one): + char_to_tgt[char_start + reference_item] = index_one + reference_item + else: + loose_target_indexes.extend(range(index_one, index_two)) + loose_char_indexes.extend(range(char_start, char_end)) + if len(loose_char_indexes) > 24: + return False + used_targets: set[int] = set() + for char_index in loose_char_indexes: + cdict = chars_by_index.get(char_value[char_index][0]) + cands = [target_index for target_index in loose_target_indexes + if target_index not in used_targets and tts[target_index] == chs[char_index]] + if not cands: + return False # a displaced char with no equal target + if cdict is not None and len(cands) > 1: + # Identical glyphs (the 21 scattered 'α' labels): + # pick the candidate whose OBJECT box sits closest + # to the char -- the one signal that distinguishes + # equal-valued slots. + origin_x, origin_y = cdict["ox"], cdict["oy"] + def _object_distance_sq(target_index: int) -> float: + item_value = objects[owner[target_index]] + delta_x = max(item_value["l"] - origin_x, 0.0, origin_x - item_value["r"]) + delta_y = max(item_value["b"] - origin_y, 0.0, origin_y - item_value["t"]) + return delta_x * delta_x + delta_y * delta_y + cands.sort(key=_object_distance_sq) + char_to_tgt[char_index] = cands[0] + used_targets.add(cands[0]) + # Leftover loose TARGETS = glyphs PDFium never emitted (the + # font-layer drop class). Record each between its + # nearest MAPPED neighbours for re-synthesis. + leftover = [target_index for target_index in loose_target_indexes if target_index not in used_targets] + if leftover: + tgt_to_char = {target_index: char_index for char_index, target_index in char_to_tgt.items()} + mapped_tis = sorted(tgt_to_char) + for target_index in leftover: + page_value = bisect.bisect_left(mapped_tis, target_index) + point_value = mapped_tis[page_value - 1] if page_value > 0 else None + normalized_token = mapped_tis[page_value] if page_value < len(mapped_tis) else None + synth_sites.append({ + "t": tts[target_index], "owner": objects[owner[target_index]], + "prev_i": char_value[tgt_to_char[point_value]][0] if point_value is not None else None, + "next_i": char_value[tgt_to_char[normalized_token]][0] if normalized_token is not None else None, + }) + for char_index, target_index in char_to_tgt.items(): + candidate_item = chars_by_index.get(char_value[char_index][0]) + if candidate_item is not None and candidate_item["obj"] is not objects[owner[target_index]]: + candidate_item["obj"] = objects[owner[target_index]] + return True + if _displacement_repair(): + return True + # Final resort: the same walk with anchored drop-skips, for + # windows containing glyphs PDFium never emitted (font-layer + # drops). The rest of the window still gets its patches and + # stream-side ownership; the dropped glyphs are recorded for + # synthesis. + if not _commit(_walk_codes(char_value, text_transform, allow_skips=True)): + failed_windows.append(list(window)) + return False + return True + # A window that resolves only by DECLARING drops (recording synth + # sites) has trusted its local char census; when chars were stolen + # ACROSS window boundaries that census lies (a starved window + # "drops" a glyph whose char sits, surplus, in another failed + # window). Track those windows so the mega pass below can supersede + # their local verdicts. + synth_windows: list[tuple[list[int], int, int]] = [] + def _run_window(window: list[int]) -> None: + before = len(synth_sites) + if _rewalk_window(window) and len(synth_sites) > before: + synth_windows.append((list(window), before, len(synth_sites))) + window: list[int] = [] + for object_index in desynced: + if window: + gap = range(window[-1] + 1, object_index) + if (len(gap) <= 2 + and all(targets_by_object_index.get(bridge_index) is not None for bridge_index in gap)): + window.extend(gap) + window.append(object_index) + continue + _run_window(window) + window = [object_index] + if window: + _run_window(window) + # Page-scope last resort: scattered same-glyph labels (dense math-heavy page's + # 21 'α' show ops over a vector figure) defeat per-window walks -- + # the geometric attribution piles several chars on some ops and + # leaves others empty ACROSS window boundaries (donor ops hold a + # stolen surplus char, starved ops none). Merge every failed AND + # every drop-declaring window into one final window so the + # displacement/skip repairs see the whole cluster at once: the + # surplus cancels the deficit, stolen chars are reassigned to their + # true ops, and only the genuine font-layer drops remain as synth + # sites. The locally-recorded sites are dropped first (the mega + # re-records with full context) and restored if the mega fails. + cand = failed_windows + [window for window, _, _ in synth_windows] + if len(cand) >= 2: + stash = synth_sites[:] + for _, font, window_end in reversed(synth_windows): + del synth_sites[font:window_end] + failed_windows = [] + mega = sorted({mega_index for window in cand for mega_index in window}) + if not _rewalk_window(mega): + synth_sites[:] = stash # mega failed: keep local verdicts + if synth_sites: + # Census gate: a recorded site is a REAL font-layer drop only if + # the PAGE-WIDE multiset still misses that value (covered ops' + # target codepoints minus PDFium's final chars). A window-local + # repair can otherwise declare a glyph dropped whose char simply + # sits, mis-attributed, in an op that walked clean -- the a clipped-cell table + # with star glyphs: 7 star codes, 7 star chars page-wide, but the + # clip-overlapped cells starve two ops, and the donors never + # fail so the mega pass can't see them. WHITESPACE is never + # synthesized: a missing space char is PDFium's textpage + # space-run normalization (text extraction runs its own space + # normalization, already implemented in the merger), not a font-layer + # drop. + census: Counter = Counter() + for text_adjustment in targets_by_object_index.values(): + if text_adjustment is not None: + for target_text in text_adjustment: + census.update(target_text) + for raw_char in raw_chars: + if not raw_char["is_gen"] and not raw_char.get("drop"): + census.subtract(raw_char["ch"]) + kept: list[dict] = [] + for encoded_text in synth_sites: + if all(_is_whitespace(ord(ch_)) for ch_ in encoded_text["t"]): + continue + if all(census[ch_] > 0 for ch_ in encoded_text["t"]): + for ch_ in encoded_text["t"]: + census[ch_] -= 1 + kept.append(encoded_text) + if kept: + _synthesize_dropped_glyphs(kept, raw_chars, chars_by_index) + return + + # Page mode. + seq: list[tuple[int, str]] = [] + char_count = pdfium_c.FPDFText_CountChars(text_page) + for char_index in range(char_count): + if pdfium_c.FPDFText_IsGenerated(text_page, char_index) == 1: + continue + codepoint = pdfium_c.FPDFText_GetUnicode(text_page, char_index) + seq.append((char_index, chr(codepoint) if codepoint > 0 else "\x00")) + targets: list[str] = [] + for font_index, encoded_text in show_codes: + if not encoded_text: + continue + text_state = targets_for(font_index, encoded_text) + if text_state is None: + return # uncovered font used on this page: no patch + targets.extend(text_state) + res = _walk_codes(seq, targets) + if res is None: + return + apply(res[0], res[1], chars_by_index) + + +def _synthesize_dropped_glyphs( + sites: list[dict], raw_chars: list[dict], chars_by_index: dict[int, dict], +) -> None: + """Re-emit glyphs PDFium's font layer never produced, even though the content stream contains them. Geometry comes from the pen model rather than a guess: PDFium still advances the pen over the missing glyph when placing surviving neighbours, so a dropped glyph starts at the previous survivor's advance-cell right edge and its advance is the gap to the next survivor's origin. With no surviving neighbour on a side, the advance is unknowable; emit zero-width there so presence and stream order are preserved without inserting a synthetic gap.""" + groups: list[list[dict]] = [] + for site in sites: + if (groups and groups[-1][0]["prev_i"] == site["prev_i"] + and groups[-1][0]["next_i"] == site["next_i"] + and groups[-1][0]["owner"] is site["owner"]): + groups[-1].append(site) + else: + groups.append([site]) + for group_value in groups: + owner = group_value[0]["owner"] + prev = chars_by_index.get(group_value[0]["prev_i"]) if group_value[0]["prev_i"] is not None else None + nxt = chars_by_index.get(group_value[0]["next_i"]) if group_value[0]["next_i"] is not None else None + text = "".join(site["t"] for site in group_value) # one char per target codepoint + count_item = len(text) + if not count_item: + continue + if prev is not None: + pen, baseline_y = prev["right"], prev["oy"] + elif nxt is not None: + pen, baseline_y = nxt["ox"], nxt["oy"] + else: + # Whole show op dropped: park at the object box's pen start. + pen, baseline_y = owner["l"], owner["b"] + total = 0.0 + if (prev is not None and nxt is not None + and abs(nxt["oy"] - baseline_y) < 0.5 and nxt["ox"] > pen): + total = nxt["ox"] - pen + adv = total / count_item + # Textpage index: fractional, slotted against the owner's own chars + # so the paint-order sort keys (page_order, i) place the run in + # stream position; only order WITHIN the owner object matters. + if prev is not None and prev["obj"] is owner: + base, sgn = prev["i"], 1.0 + elif nxt is not None and nxt["obj"] is owner: + base, sgn = nxt["i"], -1.0 + elif prev is not None: + base, sgn = prev["i"], 1.0 + elif nxt is not None: + base, sgn = nxt["i"], -1.0 + else: + base, sgn = -1.0, 1.0 + for key_value, char in enumerate(text): + is_ws, is_mn, is_cf = _char_category(char) + glyph_left = pen + adv * key_value + step = (key_value + 1) if sgn > 0 else (count_item - key_value) + raw_chars.append({ + "i": base + sgn * step * 1e-3, + "ch": char, "u": ord(char), + "is_gen": False, "synth": True, + "is_ws": is_ws, "is_mn": is_mn, "is_cf": is_cf, + "ox": glyph_left, "oy": baseline_y, + "left": glyph_left, "right": glyph_left + adv, + "top": baseline_y + owner["fs_eff"], "bottom": baseline_y, + # Degenerate ink box: PDFium reports no ink box for the glyph + # (this also keeps it out of the Type-3 extent union). + "box_top": baseline_y, "box_bottom": baseline_y, + "cell_top": baseline_y, "cell_bot": baseline_y, + "w_raw": 0.0, "w_synth": adv, + "obj": owner, "font_name": owner["font_name"], + }) diff --git a/pageindex/flash/phases/__init__.py b/pageindex/flash/phases/__init__.py new file mode 100644 index 000000000..bb263d2eb --- /dev/null +++ b/pageindex/flash/phases/__init__.py @@ -0,0 +1,50 @@ +"""Per-page pipeline orchestration. For each page, the extractor builds initial lines, computes page statistics, +detects columns, reclusters lines with column awareness, removes line-number +artifacts, recomputes statistics, and assigns reading order. +""" + +import math +from typing import Optional + +from sortedcontainers import SortedKeyList + +from ..clustering import LinesContainer, cluster_lines, build_initial_lines +from ..columns import detect_columns, ColumnDetectionContext, columns_to_x_bounds +from ..model import ( + Span, + left_aligned, + right_aligned, + center_aligned, + x_centers_close, + to_number, + Rect, + append_span, + avg_char_width, + Line, + info_weight, +) +from ..stats import column_index_of, PageStats, compute_page_stats + +from .page_view import ( + PageView, + assign_reading_order, + process_page, +) +from .line_numbers import ( + LineNumberCluster, + init_line_number_cluster, + nearest_cluster, + validate_line_number_cluster, + strip_line_numbers, +) + +__all__ = [ + "assign_reading_order", + "LineNumberCluster", + "init_line_number_cluster", + "nearest_cluster", + "validate_line_number_cluster", + "strip_line_numbers", + "PageView", + "process_page", +] diff --git a/pageindex/flash/phases/line_numbers.py b/pageindex/flash/phases/line_numbers.py new file mode 100644 index 000000000..3144440c1 --- /dev/null +++ b/pageindex/flash/phases/line_numbers.py @@ -0,0 +1,162 @@ +"""Line-number column detection and stripping.""" + +from __future__ import annotations + +import math +from typing import Optional + +from sortedcontainers import SortedKeyList +from ..model import ( + Span, + left_aligned, + right_aligned, + center_aligned, + x_centers_close, + to_number, + Rect, + append_span, + avg_char_width, + Line, + info_weight, +) + + +# --------------------------------------------------------------------------- # +# Line-number stripper # +# --------------------------------------------------------------------------- # + + +class LineNumberCluster: + """Drop-cap or line-number cluster used to detect removable line numbers.""" + + __slots__ = ("lines", "left", "secondary_slot", "primary_slot", "is_valid_sequence") + + def __init__(self, line: Line, candidate_item: float, valid_sequence_flag: bool): + self.lines: list = [line] + self.left: float = line.left_edge() + self.secondary_slot: float = avg_char_width(line) + self.primary_slot: float = candidate_item + self.is_valid_sequence: bool = valid_sequence_flag + + +def init_line_number_cluster(line: Line) -> LineNumberCluster: + """Build an initial line-number cluster for a candidate line.""" + line_number = to_number(line.primary_slot[0].state_slot) + is_valid_integer = (line_number > 0 and line_number < 1e4 and not math.isnan(line_number) and line_number == math.floor(line_number)) + return LineNumberCluster(line, line_number, is_valid_integer) + + +def nearest_cluster(line: LineNumberCluster, other_line: Optional[LineNumberCluster], candidate_line: Optional[LineNumberCluster]) -> Optional[LineNumberCluster]: + """Pick the nearer left or right cluster within two character heights.""" + distance = (line.left - other_line.left) if other_line is not None else math.inf + candidate_distance = (candidate_line.left - line.left) if candidate_line is not None else math.inf + tol = 2 * line.secondary_slot + if distance > tol and candidate_distance > tol: + return None + return other_line if distance < candidate_distance else candidate_line + + +def validate_line_number_cluster(rect: Rect, other_lines: list[Line], candidate_line: LineNumberCluster) -> bool: + """validate a candidate cluster (>= 5 lines, near left edge, bulk of body weight overlapping the cluster's vertical span).""" + if len(candidate_line.lines) < 5: + return False + if candidate_line.left < 0.05 * rect.bbox_width(): + return True + empty_line_count = 0 + flag = False + top = -math.inf + bot = math.inf + min_gap = math.inf + max_gap = -math.inf + prev: Optional[Line] = None + for cluster_line in candidate_line.lines: + if cluster_line.char_count() - cluster_line.char_stats.primary_slot[1] <= 0: + empty_line_count += 1 + first = cluster_line.alignment_slot + if first and first.char_stats.secondary_slot == 3: + flag = True + top = max(top, cluster_line.top_edge()) + bot = min(bot, cluster_line.bottom_edge()) + if prev is not None: + gap = prev.bottom_edge() - cluster_line.bottom_edge() + min_gap = min(min_gap, gap) + max_gap = max(max_gap, gap) + prev = cluster_line + # Preserve IEEE-754 division for the spacing-ratio test. + + if min_gap != 0: + gap_ratio = max_gap / min_gap + elif max_gap != 0: + gap_ratio = math.copysign(math.inf, max_gap) + else: + gap_ratio = math.nan + if empty_line_count < len(candidate_line.lines) / 2 and (not flag or gap_ratio > 1.3): + return False + total = 0.0 + covered = 0.0 + for line in other_lines: + block_weight = info_weight(line.char_stats) + total += block_weight + if line.bottom_edge() < top and line.top_edge() > bot: + covered += block_weight + return covered >= 0.8 * total + + +def strip_line_numbers(rect: Rect, other_lines: list[Line]) -> list[Line]: + """Detect a column of line numbers and strip it. Returns the original lines if no line-numbering pattern is detected.""" + # Cluster candidates by ``left`` x-position. SortedKeyList by left. + cluster_tree: SortedKeyList = SortedKeyList(key=lambda line_key: line_key.left) + for line in other_lines: + if len(line.primary_slot) == 0 or len(line.primary_slot[0].state_slot) == 0: + continue + if line.left_edge() > 0.15 * rect.bbox_width(): + continue + candidate_cluster = init_line_number_cluster(line) + if not candidate_cluster.is_valid_sequence: + continue + # Equal-left clusters must merge, so predecessor/successor lookup is + # inclusive: successor = first left >= current, predecessor = last left <= + # current. Strict bisect would fragment a fixed-x line-number column. + idx_succ = cluster_tree.bisect_left(candidate_cluster) + successor_cluster: Optional[LineNumberCluster] = ( + cluster_tree[idx_succ] if idx_succ < len(cluster_tree) else None + ) # type: ignore[assignment] + idx_pred = cluster_tree.bisect_right(candidate_cluster) + neighbor: Optional[LineNumberCluster] = ( + cluster_tree[idx_pred - 1] if idx_pred > 0 else None + ) # type: ignore[assignment] + match = nearest_cluster(candidate_cluster, neighbor, successor_cluster) + if match is not None: + if match.is_valid_sequence: + match.is_valid_sequence = (candidate_cluster.primary_slot == match.primary_slot + 1) + match.lines.append(line) + match.primary_slot = candidate_cluster.primary_slot + else: + cluster_tree.add(candidate_cluster) + + # Find largest valid (Ua) cluster + best: Optional[LineNumberCluster] = None + for cluster in cluster_tree: + if cluster.is_valid_sequence and (best is None or len(cluster.lines) > len(best.lines)): + best = cluster + if best is None or not validate_line_number_cluster(rect, other_lines, best): + return other_lines + + # Build output: for each affected line, drop its first span + affected = set(id(line) for line in best.lines) + out: list[Line] = [] + for source_line in other_lines: + if id(source_line) not in affected: + out.append(source_line) + continue + new_line = Line() + first_span = source_line.primary_slot[0] + for span in source_line: + if span is first_span: + continue + append_span(new_line, span) + if new_line.char_count() <= 0: + continue + new_line.measure_slot = source_line.measure_slot + out.append(new_line) + return out diff --git a/pageindex/flash/phases/page_view.py b/pageindex/flash/phases/page_view.py new file mode 100644 index 000000000..464b0506f --- /dev/null +++ b/pageindex/flash/phases/page_view.py @@ -0,0 +1,123 @@ +"""Per-page processing driver and reading-order assignment.""" + +from __future__ import annotations + +from typing import Optional + +from ..clustering import LinesContainer, cluster_lines, build_initial_lines +from ..columns import detect_columns, ColumnDetectionContext, columns_to_x_bounds +from ..model import ( + Span, + left_aligned, + right_aligned, + center_aligned, + x_centers_close, + to_number, + Rect, + append_span, + avg_char_width, + Line, + info_weight, +) +from ..stats import column_index_of, PageStats, compute_page_stats + +from .line_numbers import strip_line_numbers + + +# --------------------------------------------------------------------------- # +# Per-page reading order and paragraph-break flagging # +# --------------------------------------------------------------------------- # + + +class PageView: + """Per-page mutable state carried through layout classification.""" + + __slots__ = ( + "bounds", "output_slot", "secondary_slot", "measure_slot", "page_index", "primary_slot", "tertiary_slot", "lines", "blocks", + "text", "previous_slot", "annotations", + "auxiliary_slot", "state_slot", "style_slot", "option_slot", "viewport_box", "rot", + ) + + def __init__(self, page_num: int, page_bbox: Rect): + self.bounds: Rect = page_bbox + self.output_slot: list = [] + self.secondary_slot: list = [] + self.measure_slot: bool = False # set when a labeled section appears + self.page_index: int = page_num + self.primary_slot: Optional[PageStats] = None + self.tertiary_slot: list = [] # column rects + self.lines: list = [] + self.blocks: list = [] + self.text: Optional[list] = None # raw text items reconstructed by parser + self.previous_slot = 0.0 + self.annotations = [] + # per-page fields used by heading detection and outline assembly: + self.auxiliary_slot: bool = False # marked as references page + self.state_slot: bool = False # has substantive body + self.style_slot: set = set() # set of body-style hashes (sh) + self.option_slot = None # reserved, unused here + # Page viewport for heading coordinates: unrotated view box + /Rotate. + # None -> fallback to the origin-0 upright shortcut. + self.viewport_box: Optional[tuple] = None + self.rot: int = 0 + + +def assign_reading_order(primary_item: PageView, other_items: list) -> None: + """Assign reading order and paragraph-break flags for a page. The column-aware path expects blocks, not raw lines, because the sort key reads the first child line's column index. Passing raw lines would read a different flag from the first span.""" + primary_item.output_slot = other_items + for candidate_item in range(len(other_items)): + setattr(other_items[candidate_item], "orig_index", candidate_item) + + primary_item.secondary_slot = list(other_items) + primary_item.secondary_slot.sort(key=lambda sort_block: (column_index_of(sort_block), -sort_block.top_edge(), -sort_block.bottom_edge(), sort_block.left_edge(), sort_block.right_edge())) + + # Assign sorted index and paragraph/end-isolated flags to each item. + for idx in range(len(primary_item.secondary_slot)): + candidate_item = primary_item.secondary_slot[idx] + candidate_item.reading_order_index = idx + reference_item = primary_item.secondary_slot[idx + 1] if idx + 1 < len(primary_item.secondary_slot) else None + # Isolated-centered is true when the item is centered on the page and + # either has no successor, is vertically separated from it, or is not + # left/right aligned with it. Non-page-centered items can still be + # isolated if they are centered relative to a page-centered successor. + if candidate_item.alignment_slot and x_centers_close(primary_item.bounds, candidate_item): + candidate_item.isolated_centered = (not reference_item) or (reference_item.top_edge() > candidate_item.bottom_edge()) or (not left_aligned(candidate_item, reference_item, 1) and not right_aligned(candidate_item, reference_item, 1)) + else: + candidate_item.isolated_centered = bool( + candidate_item.alignment_slot and reference_item + and not left_aligned(candidate_item, reference_item, 1) and not right_aligned(candidate_item, reference_item, 1) + and center_aligned(candidate_item, reference_item, candidate_item.bbox_width() / 10) and x_centers_close(primary_item.bounds, reference_item) + ) + + +# --------------------------------------------------------------------------- # +# Per-page orchestrator # +# --------------------------------------------------------------------------- # + + +def process_page(spans: list[Span], page_num: int, page_bbox: Rect) -> PageView: + """Run the full per-page pipeline on flat span input.""" + page = PageView(page_num, page_bbox) + # Raw parser items are kept before clustering + # so document statistics can accumulate the script-family histogram over them (the lines + # below are merged + line-number-stripped, a different character multiset). + page.text = spans + # 1) Build initial lines. + container = LinesContainer() + container.primary_slot = build_initial_lines(spans, page_bbox) + # 2) First clustering pass: no column info yet. + cluster_lines(container, 0.75, []) + # 3) Compute first-pass per-page stats. + page.primary_slot = compute_page_stats(page_bbox, container.primary_slot) + # 4) Detect column rectangles and assign each line's column index. + column_context = ColumnDetectionContext(page_bbox, page.primary_slot, container.primary_slot) + page.tertiary_slot = detect_columns(column_context) + # 5) Second clustering pass: tighter tolerance with column info. + cols = columns_to_x_bounds(page.tertiary_slot) + cluster_lines(container, 0.5, cols) + # 6) Strip line-number column if present. + container.primary_slot = strip_line_numbers(page_bbox, container.primary_slot) + # 7) Recompute stats on cleaned lines. + page.primary_slot = compute_page_stats(page_bbox, container.primary_slot) + page.lines = container.primary_slot + return page diff --git a/pageindex/flash/stats/__init__.py b/pageindex/flash/stats/__init__.py new file mode 100644 index 000000000..ccfc5f3b5 --- /dev/null +++ b/pageindex/flash/stats/__init__.py @@ -0,0 +1,48 @@ +"""Page-level and document-level layout statistics. The statistics layer computes weighted percentiles, dominant styles, script +families, page spacing measures, and document-wide recurrence signals used by +classification and outline assembly. +""" + +import functools +import json +import math +from pathlib import Path +from typing import Optional + +from ..model import Span, _format_half_up_one_decimal, Line, info_weight, _max_nan_propagating + +from .scripts import ( + _SCRIPT_BUCKET_TABLE_PATH, + SCRIPT_BUCKET_TABLE, + char_script_bucket, + SCRIPT_FAMILY_WEIGHTS, + ScriptHistogram, + tally_scripts, + dominant_script_family, +) +from .aggregates import ( + _percentile_sample_cmp, + weighted_percentile, + style_key, + PageStats, + compute_page_stats, + DocStats, + compute_doc_stats, + column_index_of, +) + +__all__ = [ + "weighted_percentile", + "style_key", + "PageStats", + "compute_page_stats", + "DocStats", + "compute_doc_stats", + "column_index_of", + "char_script_bucket", + "tally_scripts", + "dominant_script_family", + "ScriptHistogram", + "SCRIPT_FAMILY_WEIGHTS", + "SCRIPT_BUCKET_TABLE", +] diff --git a/pageindex/flash/stats/aggregates.py b/pageindex/flash/stats/aggregates.py new file mode 100644 index 000000000..422cf78da --- /dev/null +++ b/pageindex/flash/stats/aggregates.py @@ -0,0 +1,313 @@ +"""Page-level and document-level statistics aggregation.""" + +from __future__ import annotations + +import functools +import math +from typing import Optional + +from ..model import Span, _format_half_up_one_decimal, Line, info_weight, _max_nan_propagating + +from .scripts import ( + ScriptHistogram, + tally_scripts, + dominant_script_family, +) + + +# --------------------------------------------------------------------------- # +# Weighted percentile. +# --------------------------------------------------------------------------- # + + +def _percentile_sample_cmp(values: tuple[float, float], other_values: tuple[float, float]) -> float: + """Comparator for weighted percentile samples. NaN comparison results are treated as equal so insertion order is preserved for NaN-valued samples.""" + if values[0] != other_values[0]: + return values[0] - other_values[0] + return values[1] - other_values[1] + + +def weighted_percentile(values: list[tuple[float, float]], other_item: float) -> float: + """Weighted percentile over ``(value, weight)`` samples. Returns ``NaN`` for empty input or an out-of-range percentile. Ties at the target weight return the average of current and previous values; overshoots return the current value.""" + if len(values) <= 0 or other_item < 0 or other_item > 100: + return float("nan") + samples = sorted(values, key=functools.cmp_to_key(_percentile_sample_cmp)) # type: ignore[arg-type] + total = sum(page_value[1] for page_value in samples) + target = total * other_item / 100.0 + candidate_item = 0.0 + reference_item: Optional[float] = None + for value, weight in samples: + if candidate_item == target: + return value if reference_item is None else (reference_item + value) / 2.0 + reference_item = value + candidate_item += weight + if candidate_item > target: + return value + return float("nan") if reference_item is None else reference_item + + +# --------------------------------------------------------------------------- # +# Per-span style hash # +# --------------------------------------------------------------------------- # + + +def style_key(span: Span) -> str: + """Return ``" "`` for same-style span histograms.""" + return f"{span.font_style()} {_format_half_up_one_decimal(span.font_size)}" + + +# --------------------------------------------------------------------------- # +# Per-page statistics # +# --------------------------------------------------------------------------- # + + +class PageStats: + """Per-page layout statistics used by column detection and classification.""" + + __slots__ = ("line_count", "secondary_slot", "tertiary_slot", "previous_slot", "style_slot", "cache_slot", "option_slot", "primary_slot", "measure_slot", "state_slot", "auxiliary_slot") + + def __init__( + self, + valid_line_count: int, + total_line_weight: float, + median_overlap_gap: float, + median_line_width: float, + median_char_count: float, + median_area_metric: float, + median_center_y: float, + median_font_size: float, + average_char_width: float, + dominant_font: str, + dominant_style: str, + ): + self.line_count = valid_line_count + self.secondary_slot = total_line_weight + self.tertiary_slot = median_overlap_gap + self.previous_slot = median_line_width + self.style_slot = median_char_count + self.cache_slot = median_area_metric + self.option_slot = median_center_y + self.primary_slot = median_font_size + self.measure_slot = average_char_width + self.state_slot = dominant_font + self.auxiliary_slot = dominant_style + + +# --------------------------------------------------------------------------- # +# Per-page statistics. +# --------------------------------------------------------------------------- # + + +def compute_page_stats(page, other_lines: list[Line]) -> PageStats: + """Compute weighted medians plus dominant font/style for one page.""" + overlap_gap_samples: list[tuple[float, float]] = [] # bucket-overlap samples + line_width_samples: list[tuple[float, float]] = [] # line-width samples + char_count_samples: list[tuple[float, float]] = [] # line-char-count samples + area_metric_samples: list[tuple[float, float]] = [] # line.U samples + array: list[tuple[float, float]] = [] # y-center samples + font_size_samples: list[tuple[float, float]] = [] # font-size samples + + font: dict[str, float] = {} # font-name histogram (weighted) + style: dict[str, float] = {} # style-hash histogram (weighted) + chars = 0 # total char count across spans + width = 0.0 # total width across spans + total = 0.0 # total line weight (sum of tf) + valid = 0 # valid line count + + bucket_size = page.bbox_width() / 20.0 # page width / 20 buckets + buckets: list[Optional[Line]] = [None] * 21 # 20 buckets, +1 guard + + for line in other_lines: + if line.skew_frac() > 1: # rotated/skewed line: skip + continue + valid += 1 + for span in line: # for each span t in line u + span_weight = info_weight(span.char_stats) + span_weight = span_weight * span_weight * span.bbox_height() # weight = tf^2 * height + font[span.font_name] = font.get(span.font_name, 0.0) + span_weight + sty = style_key(span) + style[sty] = style.get(sty, 0.0) + span_weight + chars += span.char_count() + width += span.bbox_width() + line_weight = info_weight(line.char_stats) + total += line_weight + sample = line_weight * line.avg_font_size() # weight = line_weight * font_size + font_size_samples.append((line.avg_font_size(), sample)) + line_width_samples.append((line.bbox_width(), sample)) + char_count_samples.append((line.char_count(), sample)) + area_metric_samples.append((line.cache_slot, line.area())) # NB: this one is weighted by area + array.append((line.center_y(), sample)) + + # Vertical overlap with the most recent occupant of each horizontal + # bucket. Infinite sentinel boxes skip overlap sampling. + + left = line.left_edge() + right = line.right_edge() + if not (left < float("inf") and right > float("-inf")): + continue + if bucket_size <= 0: + # Degenerate zero-width pages skip overlap sampling; downstream + # statistics still include font and line-width samples. + continue + # Clamp infinite sentinels before converting bucket indexes to integers. + bucket_left = 0 if left == float("-inf") else max(0, int(left / bucket_size)) + bucket_right = 20 if right == float("inf") else min(20, math.ceil(right / bucket_size)) + best_gap = float("inf") + best_prev: Optional[Line] = None + idx = bucket_left + while idx < bucket_right: + prev_in_bucket = buckets[idx] + buckets[idx] = line + idx += 1 + if prev_in_bucket is None: + continue + gap = max(prev_in_bucket.bottom_edge(), line.top_edge()) - line.bottom_edge() + if gap < best_gap: + best_gap = gap + best_prev = prev_in_bucket + if best_gap < float("inf") and best_prev is not None: + overlap_gap_samples.append((best_gap, info_weight(best_prev.char_stats) * line_weight)) + + # Dominant values update only on strictly greater positive weight. This + # keeps the empty value for all-zero pages and preserves first-seen ties. + dominant_font = "" + dominant_font_weight = 0.0 + for font_name, weight in font.items(): + if weight > dominant_font_weight: + dominant_font_weight = weight + dominant_font = font_name + dominant_style = "" + dominant_style_weight = 0.0 + for style_name, weight in style.items(): + if weight > dominant_style_weight: + dominant_style_weight = weight + dominant_style = style_name + + return PageStats( + valid_line_count=valid, + total_line_weight=total, + median_overlap_gap=weighted_percentile(overlap_gap_samples, 50), + median_line_width=weighted_percentile(line_width_samples, 50), + median_char_count=weighted_percentile(char_count_samples, 50), + median_area_metric=weighted_percentile(area_metric_samples, 50), + median_center_y=weighted_percentile(array, 50), + median_font_size=weighted_percentile(font_size_samples, 50), + # Average char width with IEEE edge cases: no characters with positive + # width yields +inf, and no characters with no width yields NaN. + average_char_width=(width / chars) if chars != 0 + else (float("inf") if width > 0 else float("nan")), + dominant_font=dominant_font, + dominant_style=dominant_style, + ) + + +# --------------------------------------------------------------------------- # +# Document-level statistics # +# --------------------------------------------------------------------------- # + + +class DocStats: + """Document-level layout statistics: dominant script family, landscape-page count, total valid lines, total line weight, max page line weight, median page total weight, width/height percentiles, center statistic, and median body font size.""" + + __slots__ = ("tertiary_slot", "style_slot", "cache_slot", "state_slot", "previous_slot", "secondary_slot", "option_slot", "auxiliary_slot", "measure_slot", "primary_slot") + + def __init__( + self, + dominant_script: int, + landscape_pages: int, + total_lines: int, + total_weight: float, + max_page_weight: float, + median_page_weight: float, + median_line_width: float, + upper_width_percentile: float, + median_center_y: float, + median_body_font_size: float, + ): + self.tertiary_slot = dominant_script + self.style_slot = landscape_pages + self.cache_slot = total_lines + self.state_slot = total_weight + self.previous_slot = max_page_weight + self.secondary_slot = median_page_weight + self.option_slot = median_line_width + self.auxiliary_slot = upper_width_percentile + self.measure_slot = median_center_y + self.primary_slot = median_body_font_size + + +# --------------------------------------------------------------------------- # +# Document-level statistics. +# --------------------------------------------------------------------------- # + + +def compute_doc_stats(pages: list) -> DocStats: + """Compute document-wide recurrence and script statistics from page records.""" + script = ScriptHistogram() + landscape = 0 + total_lines = 0 + total_weight = 0.0 + max_weight = 0.0 + + total_weights: list[tuple[float, float]] = [] + bucket_overlaps: list[tuple[float, float]] = [] + widths: list[tuple[float, float]] = [] + char_counts: list[tuple[float, float]] = [] + upper_samples: list[tuple[float, float]] = [] + centers: list[tuple[float, float]] = [] + font_sizes: list[tuple[float, float]] = [] + + for query_value in pages: + # Accumulate the script-family histogram over raw parser text items, + # capped at 100k chars. Merged line text can omit line-number spans. + if script.secondary_slot < 100_000: + for span in (query_value.text or []): + if script.secondary_slot >= 100_000: + break + tally_scripts(script, span.text) + if query_value.bounds.bbox_width() > query_value.bounds.bbox_height(): + landscape += 1 + stats: PageStats = query_value.primary_slot + total_lines += stats.line_count + weight = stats.secondary_slot + total_weight += weight + max_weight = _max_nan_propagating(max_weight, weight) + if stats.line_count <= 0 or weight <= 0: + continue + per_page = min(100.0, weight / stats.line_count) + total_weights.append((weight, per_page)) + bucket_overlaps.append((stats.tertiary_slot, per_page)) + widths.append((stats.previous_slot, per_page)) + char_counts.append((stats.style_slot, per_page)) + upper_samples.append((stats.cache_slot, per_page)) + centers.append((stats.option_slot, per_page)) + font_sizes.append((stats.primary_slot, per_page)) + + return DocStats( + dominant_script=dominant_script_family(script), # dominant script family + landscape_pages=landscape, + total_lines=total_lines, + total_weight=total_weight, + max_page_weight=max_weight, + median_page_weight=weighted_percentile(total_weights, 50), + # Width and uppercase samples are the document-wide outputs used later. + median_line_width=weighted_percentile(widths, 50), + upper_width_percentile=weighted_percentile(upper_samples, 80), # NB: 80th percentile, not 50 + median_center_y=weighted_percentile(centers, 50), + median_body_font_size=weighted_percentile(font_sizes, 50), + ) + + +# --------------------------------------------------------------------------- # +# Column-index accessor # +# --------------------------------------------------------------------------- # + + +def column_index_of(line: Line) -> int: + """Return the column index stored on the first child line/span. In normal use this receives a block, so the first child is a line and its stored column index is returned. If a raw line is passed, the same field access still succeeds but reads a different flag; the block-clustering pipeline avoids that path for column-aware ordering.""" + if not line.primary_slot: + return -1 + first = line.primary_slot[0] + # If ``first`` is another container (block.g[0] is a line) consult its H. + value = getattr(first, "measure_slot", None) + return -1 if value is None else value diff --git a/pageindex/flash/stats/scripts.py b/pageindex/flash/stats/scripts.py new file mode 100644 index 000000000..5b6c90c03 --- /dev/null +++ b/pageindex/flash/stats/scripts.py @@ -0,0 +1,85 @@ +"""Script bucket tables and script histogram helpers.""" + +from __future__ import annotations + +import json +from pathlib import Path + + +# --------------------------------------------------------------------------- # +# Script-family detector and bucket table. +# --------------------------------------------------------------------------- # + +_SCRIPT_BUCKET_TABLE_PATH = Path(__file__).parent.parent / "data" / "script_bucket_table.json" +SCRIPT_BUCKET_TABLE: list[int] = json.loads(_SCRIPT_BUCKET_TABLE_PATH.read_text(encoding="utf-8")) + + +def char_script_bucket(text: str) -> int: + """Return the script-bucket id for a single character: empty/multi-character, ASCII punctuation/digit, control, ASCII letter, or a table-driven non-Latin script bucket.""" + if not text: + return 0 + if len(text) != 1: + return 10 + # Astral code points are classified as the multi-unit script bucket. + if ord(text) > 0xFFFF: + return 10 + if ("a" <= text <= "z") or ("A" <= text <= "Z"): + return 3 + if "\x00" < text < " ": + return 2 + if text < "€": + return 1 + idx = ord(text) >> 4 + if 0 <= idx < len(SCRIPT_BUCKET_TABLE): + return SCRIPT_BUCKET_TABLE[idx] + return 0 + + +# map: each output category -> contributing bucket weights. +# Fixed weights for collapsing script buckets into document script families. +SCRIPT_FAMILY_WEIGHTS: dict[int, list[tuple[int, int]]] = { + 2: [(2, 10)], + 0: [(0, 1), (2, 1)], + 3: [(3, 1), (4, -3), (5, -3), (6, -3), (7, -3), (8, -3), (9, -10)], + 4: [(4, 1)], + 5: [(5, 1), (6, -10), (7, -10)], + 6: [(6, 1)], + 7: [(7, 1)], + 8: [(8, 1)], + 9: [(9, 1)], + 10: [(10, 1)], +} + + +class ScriptHistogram: + """Script-bucket accumulator with total character count and per-bucket histogram.""" + + __slots__ = ("secondary_slot", "primary_slot") + + def __init__(self): + self.secondary_slot: int = 0 + self.primary_slot: list[int] = [0] * 11 + + +def tally_scripts(primary_item: ScriptHistogram, other_text: str) -> None: + """feed a string into the bucket accumulator.""" + for candidate_item in other_text: + primary_item.primary_slot[char_script_bucket(candidate_item)] += 1 + primary_item.secondary_slot += 1 + + +def dominant_script_family(primary_item: ScriptHistogram) -> int: + """best-scoring script-family for the accumulator. Returns the output category 0..10 with the highest weighted score. """ + secondary_item = 0 + candidate_item = 0 + for reference_item in range(11): + entry_item = SCRIPT_FAMILY_WEIGHTS.get(reference_item) + if not entry_item: + continue + score_value = 0 + for (script_index, weight) in entry_item: + score_value += weight * primary_item.primary_slot[script_index] + if score_value > candidate_item: + secondary_item = reference_item + candidate_item = score_value + return secondary_item diff --git a/pageindex/flash/title/__init__.py b/pageindex/flash/title/__init__.py new file mode 100644 index 000000000..77d5cdd2a --- /dev/null +++ b/pageindex/flash/title/__init__.py @@ -0,0 +1,54 @@ +"""Document-title detection. The scoring formula is the heart of title detection: score is a product of layout, recurrence, label, script, width, numbering, punctuation, alignment, and page-position factors. Each factor is in roughly ``[0.1, 3.0]-- the product can grow to a few +thousand for a strong title candidate. The factors are documented in the +scoring body. The multilingual title-keyword and institution-word sets are stored in +``data/dictionaries.json`` as ``title`` and ``institution_words``. +""" + +import json +import math +import unicodedata +from pathlib import Path +from typing import Optional + +from ..model import ( + _trim_unicode_ws, + left_aligned, + right_aligned, + center_aligned, + Rect, + last_span, + heading_score, + Line, + last_line_of, + first_span_of, + block_text, + deaccented_text, + letter_count, + dominant_style_of, + info_weight, + is_upper_dominant, + alignment_code, + Block, +) +from ..stats import DocStats, column_index_of, tally_scripts, dominant_script_family, ScriptHistogram +from ..tokens import is_superscript_adjacent, clamp_value, enumerate_tokens, jenkins_hash, trie_prefix_match, set_case_fold, TrieConfig, build_trie, tokenize_block, _de_norm, BuiltTrie, is_word_token + +from .dicts import ( + _DICT_PATH, + _normalize_text_key, + _load_dicts, + INSTITUTION_WORDS, + TITLE_LABEL_TRIE, +) +from .scoring import ( + TitleCandidate, + is_cover_like_page, + is_title_candidate_block, + score_title_candidate, +) +from .detect import ( + TitleSearchState, + detect_title, +) + +__all__ = ["is_title_candidate_block", "score_title_candidate", "TitleSearchState", "TitleCandidate", "detect_title", "is_cover_like_page", "TITLE_LABEL_TRIE", "INSTITUTION_WORDS"] diff --git a/pageindex/flash/title/detect.py b/pageindex/flash/title/detect.py new file mode 100644 index 000000000..49a022227 --- /dev/null +++ b/pageindex/flash/title/detect.py @@ -0,0 +1,140 @@ +"""Document title search over early pages.""" + +from __future__ import annotations + +from typing import Optional + +from ..model import ( + _trim_unicode_ws, + left_aligned, + right_aligned, + center_aligned, + Rect, + last_span, + heading_score, + Line, + last_line_of, + first_span_of, + block_text, + deaccented_text, + letter_count, + dominant_style_of, + info_weight, + is_upper_dominant, + alignment_code, + Block, +) +from ..tokens import is_superscript_adjacent, clamp_value, enumerate_tokens, jenkins_hash, trie_prefix_match, set_case_fold, TrieConfig, build_trie, tokenize_block, _de_norm, BuiltTrie, is_word_token + +from .scoring import ( + TitleCandidate, + is_cover_like_page, + is_title_candidate_block, + score_title_candidate, +) + + +# --------------------------------------------------------------------------- # +# Title detection state. +# --------------------------------------------------------------------------- # + + +class TitleSearchState: + """Title-detection state: document, visited blocks, and current best candidate.""" + + __slots__ = ("tertiary_slot", "primary_slot", "secondary_slot") + + def __init__(self, doc): + self.tertiary_slot = doc + self.primary_slot: set = set() + self.secondary_slot: Optional[TitleCandidate] = None + + +# --------------------------------------------------------------------------- # +# Title-detection driver. +# --------------------------------------------------------------------------- # + + +def detect_title(doc) -> Optional[TitleCandidate]: + """Iterate early pages, score title-like block groups, and return the best candidate.""" + state = TitleSearchState(doc) + has_seen_da = False # "broke into body" flag + + for page in doc.primary_slot: + # Special branch: landscape cover document + if ( + doc.secondary_slot.style_slot > len(doc.primary_slot) / 2 + and page.page_index <= 1 + and page.bounds.bbox_width() > page.bounds.bbox_height() + and page.primary_slot.secondary_slot < 500 + ): + for idx, block in enumerate(page.secondary_slot): + if ( + is_title_candidate_block(block) and id(block) not in state.primary_slot + and heading_score(block) > page.primary_slot.primary_slot - 0.1 + ): + score_title_candidate(state, page, idx) + break + + if ( + is_cover_like_page(doc, page) + or (page.page_index <= 1 and len(doc.primary_slot) >= 10 and page.primary_slot.secondary_slot < 0.8 * doc.secondary_slot.secondary_slot) + ): + # Cover / front-matter page + for idx, block in enumerate(page.secondary_slot): + if not is_title_candidate_block(block) or id(block) in state.primary_slot: + continue + score = heading_score(block) + if ( + (score > doc.secondary_slot.primary_slot + 0.1 and score > page.primary_slot.primary_slot + 0.1) + or (score > doc.secondary_slot.primary_slot + 2 and score > page.primary_slot.primary_slot - 0.1) + or (score > doc.secondary_slot.primary_slot - 0.1 and score > page.primary_slot.primary_slot - 0.1 and block.isolated_centered) + or (score > doc.secondary_slot.primary_slot - 0.1 and score > page.primary_slot.primary_slot - 0.1 + and page.page_index <= 1 and page.primary_slot.secondary_slot < 500) + ): + score_title_candidate(state, page, idx) + else: + # Body page: only consider initial blocks until we hit body text + local_done = False + for idx, block in enumerate(page.secondary_slot): + if id(block) in state.primary_slot: + continue + score = heading_score(block) + # Block clearly larger than body + size_trigger = ( + is_title_candidate_block(block) and ( + (score > doc.secondary_slot.primary_slot + 0.1 and score > page.primary_slot.primary_slot + 0.1) + or (score > doc.secondary_slot.primary_slot + 2 and score > page.primary_slot.primary_slot - 0.1) + or (block.isolated_centered and score > page.primary_slot.primary_slot - 0.1) + or (page.page_index == 1 and score > page.primary_slot.primary_slot + 2) + ) + ) + if size_trigger: + score_title_candidate(state, page, idx) + elif block.is_body_paragraph and not block.isolated_centered: + # Body-break flag: stop scanning once body text is reached. + if not has_seen_da: + if (block.bottom_edge() - page.bounds.bottom_edge() < 2 * page.bounds.bbox_height() / 3): + has_seen_da = False + elif block.line_count() >= 3 and alignment_code(block) == 4: + has_seen_da = True + else: + digit_or_period = 0 + tokens = tokenize_block(block) + for token in tokens: + if is_word_token(token) or token.type == 1: + digit_or_period += 1 + has_seen_da = digit_or_period >= len(tokens) / 3 + has_seen_da = not has_seen_da + if has_seen_da: + local_done = True + break + local_done = True + # Once body text is seen, the flag stays sticky so a later + # body block on this page breaks immediately. + has_seen_da = True + if local_done: + break + if doc.secondary_slot.secondary_slot < 400: + break + return state.secondary_slot diff --git a/pageindex/flash/title/dicts.py b/pageindex/flash/title/dicts.py new file mode 100644 index 000000000..42e07dc22 --- /dev/null +++ b/pageindex/flash/title/dicts.py @@ -0,0 +1,35 @@ +"""Dictionary tables for title detection.""" + +from __future__ import annotations + +import json +import unicodedata +from pathlib import Path +from ..tokens import is_superscript_adjacent, clamp_value, enumerate_tokens, jenkins_hash, trie_prefix_match, set_case_fold, TrieConfig, build_trie, tokenize_block, _de_norm, BuiltTrie, is_word_token + + +# --------------------------------------------------------------------------- # +# Load title-label and institution dictionaries # +# --------------------------------------------------------------------------- # + + +_DICT_PATH = Path(__file__).parent.parent / "data" / "dictionaries.json" + + +def _normalize_text_key(text: str) -> str: + """NFKC + strip + collapse-whitespace + lowercase.""" + return " ".join(unicodedata.normalize("NFKC", text).strip().split()).lower() + + +def _load_dicts() -> tuple[BuiltTrie, set[str]]: + raw = json.loads(_DICT_PATH.read_text(encoding="utf-8")) + title_label_trie = build_trie(raw.get("title", []), set_case_fold(TrieConfig(), True)) + # institution words use normalized single-token set membership. + # The title-label dictionary stays a trie because it handles the + + # multi-token "Title:" match; institution words are single-token only.) + institution_words = set(raw.get("institution_words", [])) + return title_label_trie, institution_words + + +TITLE_LABEL_TRIE, INSTITUTION_WORDS = _load_dicts() diff --git a/pageindex/flash/title/scoring.py b/pageindex/flash/title/scoring.py new file mode 100644 index 000000000..5d5666299 --- /dev/null +++ b/pageindex/flash/title/scoring.py @@ -0,0 +1,264 @@ +"""Title-candidate scoring.""" + +from __future__ import annotations + +import math + +from ..model import ( + _trim_unicode_ws, + left_aligned, + right_aligned, + center_aligned, + Rect, + last_span, + heading_score, + Line, + last_line_of, + first_span_of, + block_text, + deaccented_text, + letter_count, + dominant_style_of, + info_weight, + is_upper_dominant, + alignment_code, + Block, +) +from ..stats import DocStats, column_index_of, tally_scripts, dominant_script_family, ScriptHistogram +from ..tokens import is_superscript_adjacent, clamp_value, enumerate_tokens, jenkins_hash, trie_prefix_match, set_case_fold, TrieConfig, build_trie, tokenize_block, _de_norm, BuiltTrie, is_word_token + +from .dicts import ( + TITLE_LABEL_TRIE, + INSTITUTION_WORDS, +) + + +# --------------------------------------------------------------------------- # +# Title candidate state container # +# --------------------------------------------------------------------------- # + + +class TitleCandidate: + """Best title candidate so far: page, contributing blocks, and score.""" + + __slots__ = ("page", "output_slot", "score") + + def __init__(self, page, blocks: list[Block], score_value: float): + self.page = page + self.output_slot = blocks + self.score = score_value + + def to_string(self) -> str: + """Join contributing blocks into the displayed title string, inserting one inter-block space only after the accumulator is non-empty.""" + primary_item = "" + for block in self.output_slot: + if primary_item: + primary_item += " " + primary_item += _trim_unicode_ws(tokenize_block(block).to_string()) + + return primary_item + + def __str__(self) -> str: + return self.to_string() + + +# --------------------------------------------------------------------------- # +# Cover-like page predicate # +# --------------------------------------------------------------------------- # + + +def is_cover_like_page(doc, page) -> bool: + """Return whether a page is sparse enough to behave like a cover page.""" + if getattr(page, "measure_slot", False): + return False + threshold = 0.5 * min(doc.secondary_slot.secondary_slot, 5e3) + if page.page_index <= 1 and page.primary_slot.secondary_slot < threshold: + return True + early_limit = 1 + min(15, len(doc.primary_slot) / 5) + return page.page_index < early_limit and page.primary_slot.secondary_slot < 0.8 * threshold + + +# --------------------------------------------------------------------------- # +# xp: candidate-block filter # +# --------------------------------------------------------------------------- # + + +def is_title_candidate_block(block: Block) -> bool: + """Return whether ``block`` can be considered as a document-title candidate.""" + return ( + letter_count(block.char_stats) > 0 + and block.skew_frac() < 1 + and block.type == 0 + and block.char_count() < 400 + and block.bbox_height() < 2 * block.bbox_width() + ) + + +# --------------------------------------------------------------------------- # +# yp: multiplicative scoring for a candidate group # +# --------------------------------------------------------------------------- # + + +def score_title_candidate(zp_state, page, index: int) -> None: + """Score a candidate block group and update the title-search state.""" + doc = zp_state.tertiary_slot + blocks = page.secondary_slot # sorted blocks + title_block = blocks[index] + title_group: list[Block] = [title_block] + + # Try to extend with next block if alignment / style / vertical proximity match + if index + 1 < len(blocks): + next_item = blocks[index + 1] + title_score = heading_score(title_block) + height = title_block.avg_font_size() + # Two acceptance conditions: + if ( + (abs(title_score - heading_score(next_item)) < 0.1 + and dominant_style_of(title_block) == dominant_style_of(next_item) + and title_block.bottom_edge() - next_item.top_edge() < height) + or ( + title_score > doc.secondary_slot.primary_slot + 5 + and title_score > page.primary_slot.primary_slot + 1 + and abs(height - next_item.avg_font_size()) < 0.1 + and title_block.bottom_edge() - next_item.top_edge() < 0.5 * height + ) + ): + tolerance = 0.1 * height + align_value = alignment_code(title_block) + next_alignment = alignment_code(next_item) + if ( + (left_aligned(title_block, next_item, tolerance) + and align_value in (1, 2) and next_alignment in (1, 2)) + or (right_aligned(title_block, next_item, tolerance) + and align_value in (1, 4) and next_alignment in (1, 4)) + or (center_aligned(title_block, next_item, tolerance) + and title_block.alignment_slot and next_item.alignment_slot) + ): + title_group.append(next_item) + + group = title_group + for measure_item in group: + zp_state.primary_slot.add(id(measure_item)) + + doc_state = zp_state.tertiary_slot + previous_block = blocks[index - 1] if index - 1 >= 0 else None + + # Accumulate statistics over the title group + max_heading_score = 0 + max_width = 0.0 + consecutive = 0 + max_consecutive = 0 + bracket_count = 0 + total_tokens = 0 + right_pen = 1.0 + email_count = 0 + + for result_value in group: + max_heading_score = max(max_heading_score, heading_score(result_value)) + max_width = max(max_width, result_value.bbox_width()) + title_tokens_view = tokenize_block(result_value) + for entry in enumerate_tokens(title_tokens_view): + sample_item = entry["token"] + total_tokens += 1 + if is_word_token(sample_item): + consecutive += 1 + max_consecutive = max(max_consecutive, consecutive) + if sample_item.boundary_slot: + bracket_count += 1 + # email detection: "@" followed by word "." word (4 tokens) + if sample_item.str == "@" and entry["index"] + 3 < title_tokens_view.length: + next_token = title_tokens_view.token_at(entry["index"] + 1) + dot = title_tokens_view.token_at(entry["index"] + 2) + after = title_tokens_view.token_at(entry["index"] + 3) + if ( + next_token is not None and dot is not None and after is not None + and next_token.type == 2 and dot.str == "." and after.type == 2 + ): + email_count += 1 + else: + consecutive = 0 + if alignment_code(result_value) == 4: + # The line count is structurally positive here. Keep the fallback so + # a degenerate line cannot raise during title scoring. + + right_pen /= result_value.line_count() or 1 + + if total_tokens <= 0: + return + + # Multiplicative factors + len_value = clamp_value(total_tokens * total_tokens / 16.0, 0.5, 1.0) + # Page width should be positive. Keep IEEE-style Infinity/NaN behavior for + # degenerate pages instead of raising during scoring. + width_ratio_sq = (max_width / page.bounds.bbox_width()) if page.bounds.bbox_width() else (math.inf if max_width > 0 else math.nan) + width_ratio_sq *= width_ratio_sq + bracket = bracket_count / total_tokens + bracket_factor = max(0.1, 1 - 9 * bracket * bracket) / max(1, max_consecutive - 2) + page_pos = max(0.1, 1 - 2 * (page.page_index - 1) / max(1, len(doc_state.primary_slot))) + # Doc-wide height is positive in normal inputs. The epsilon prevents a + # degenerate input from raising and still yields the minimum density factor. + page_density_ratio = page.primary_slot.secondary_slot / max(1e-6, doc_state.secondary_slot.secondary_slot) + density_factor = max(0.5, 1 - page_density_ratio * page_density_ratio) * (1 + clamp_value((0.25 - page_density_ratio) / 0.15, 0, 1)) + # Page top/height is positive in normal inputs. Degenerate pages take the + # minimum top-position factor instead of raising. + + top = max(0.1, group[0].top_edge() / page.bounds.top_edge()) if page.bounds.top_edge() else 0.1 + + # Abbreviation penalty: count adjacent single-char + delimiter pairs + abbrev = 0 + for block in group: + tokens = tokenize_block(block) + previous = None + for token in tokens: + if previous is not None and len(token.str) <= 1 and is_superscript_adjacent(previous, token): + abbrev += 1 + previous = token + factor = clamp_value(1.0 / max(1, abbrev), 0.3, 1.0) + + # Recurrence penalty: first block's normalized text appears how often? + # The histogram uses the same normalized text hash as the document-wide + # ghost-text map. + norm_text = jenkins_hash(deaccented_text(group[0])) + recurrence_count = doc_state.tertiary_slot.get(norm_text, 0) if hasattr(doc_state, "tertiary_slot") and isinstance(doc_state.tertiary_slot, dict) else 0 + ratio = recurrence_count / max(1, len(doc_state.primary_slot)) + adj = total_tokens - 3 + recurrence_factor = 1 - 0.5 * clamp_value(ratio / 0.3, 0, 1) * (1 / max(1, adj * adj)) + + # Institution-word penalty (non-first-page) + institution = 1.0 + if is_cover_like_page(doc_state, page): + inst_hits = 0 + for block in group: + for token in tokenize_block(block): + # single-token + # Match using the same lowercase + diacritic-stripped form as + # the institution-word set. + if _de_norm(token.str, True) in INSTITUTION_WORDS: + inst_hits += 1 + institution = 1.0 / (1 + inst_hits) + + # "Title:" label bonus from previous block + label = 1.0 + if previous_block is not None: + prev_tokens = tokenize_block(previous_block) + if prev_tokens.length <= 3 and trie_prefix_match(TITLE_LABEL_TRIE, prev_tokens) is not None: + label = 3.0 + + # Email penalty + email = 1.0 / ((1 + email_count) ** 2) + + # Script-family match: build a script histogram over the candidate group's text and + # compare the candidate script family against the document script family. + script_acc = ScriptHistogram() + for result_value in group: + tally_scripts(script_acc, block_text(result_value)) + script = 1.0 if dominant_script_family(script_acc) == doc_state.secondary_slot.tertiary_slot else 0.5 + + score = ( + max_heading_score * len_value * width_ratio_sq * right_pen * bracket_factor * page_pos + * density_factor * top * factor * recurrence_factor * institution * label + * email * script + ) + + if zp_state.secondary_slot is None or score > zp_state.secondary_slot.score: + zp_state.secondary_slot = TitleCandidate(page, group, score) diff --git a/pageindex/flash/tokens/__init__.py b/pageindex/flash/tokens/__init__.py new file mode 100644 index 000000000..edc14737d --- /dev/null +++ b/pageindex/flash/tokens/__init__.py @@ -0,0 +1,105 @@ +"""Tokenizer subsystem. The tokenizer is character-driven: it walks each character of each line, +uses category-transition tolerances to decide when the current token can +extend, and closes tokens when script, punctuation, or spacing transitions +require a boundary. Tokens keep back-references to the contributing line and span offsets so the +visible text can be reconstructed and cross-line tokens, such as a word broken +by a hyphen across two lines, can be stitched. +""" + +import unicodedata +from typing import Any, Iterable, Iterator, Optional + +from ..model import ( + _strip_diacritics, + avg_char_width2, + intervals_overlap, + to_number, + rect_union, + EMPTY_RECT, + avg_char_width, + Line, + char_category, + is_word_category, + is_punct_category, + letter_count, + punct_count, + info_weight, + Block, +) + +from .token_types import ( + SCRIPT_FAMILY_MAP, + _build_gap_tolerance_grid, + GAP_TOLERANCE_GRID, + can_extend_token, + TokenAnchor, + last_token_anchor, + first_anchor_span, + is_char_token, + is_word_token, + is_trimmable_token, + token_numeric_value, + Token, + TokenView, + wrap_tokens, + enumerate_tokens, + first_token, + last_token, +) +from .tokenizer import ( + LineTokenizer, + tokenize_block, + clamp_value, + is_superscript_adjacent, +) +from .tries import ( + _de_norm, + TrieConfig, + BuiltTrie, + set_reverse, + set_case_fold, + TrieNode, + trie_insert_step, + trie_walk_step, + aho_corasick_match, + aho_corasick_tokens, + TrieBuilder, + _trie_insert_entry, + trie_bulk_insert, + _trie_finalize, + build_trie, + trie_prefix_match, + _trie_full_match, + trie_full_match, + strip_trie_match, + strip_leading_if_in, + COMMA_CHARS, + strip_trailing_comma, + is_comma_token, + trim_trailing_punct, +) +from .hashing import ( + _FH_MASK, + _to_uint32, + _to_int32, + _int32_xor, + _int32_left_shift, + _uint32_right_shift, + _little_endian_signed_word, + _utf8_bytes_from_utf16_units, + _jenkins_mix, + jenkins_hash, +) + +__all__ = [ + # state machine + "GAP_TOLERANCE_GRID", "can_extend_token", "TokenAnchor", "last_token_anchor", "first_anchor_span", + "is_char_token", "is_word_token", "is_trimmable_token", "token_numeric_value", "Token", + "TokenView", "wrap_tokens", "enumerate_tokens", "first_token", "last_token", + "LineTokenizer", "tokenize_block", + "clamp_value", "is_superscript_adjacent", "jenkins_hash", + # trie + "TrieConfig", "BuiltTrie", "set_reverse", "set_case_fold", "TrieNode", "trie_insert_step", "trie_walk_step", "build_trie", "trie_prefix_match", "trie_full_match", + "strip_trie_match", "strip_leading_if_in", "strip_trailing_comma", "trim_trailing_punct", "COMMA_CHARS", + "SCRIPT_FAMILY_MAP", "GAP_TOLERANCE_GRID", +] diff --git a/pageindex/flash/tokens/hashing.py b/pageindex/flash/tokens/hashing.py new file mode 100644 index 000000000..b6fe04017 --- /dev/null +++ b/pageindex/flash/tokens/hashing.py @@ -0,0 +1,139 @@ +"""32-bit integer helpers and the Jenkins string hash.""" + +from __future__ import annotations + + +# --------------------------------------------------------------------------- # +# Jenkins lookup2 string hash (UTF-8 bytes -> signed 32-bit int). +# --------------------------------------------------------------------------- # + +_FH_MASK = 0xFFFFFFFF + + +def _to_uint32(number: int) -> int: + return number & _FH_MASK + + +def _to_int32(number: int) -> int: + number &= _FH_MASK + return number - 0x100000000 if number >= 0x80000000 else number + + +def _int32_xor(number: int, other_number: int) -> int: + """ToInt32 of the 32-bit xor of the operands' uint32 forms.""" + return _to_int32(_to_uint32(number) ^ _to_uint32(other_number)) + + +def _int32_left_shift(number: int, other_number: int) -> int: + """(signed 32-bit result).""" + return _to_int32((_to_uint32(number) << (other_number & 31)) & _FH_MASK) + + +def _uint32_right_shift(number: int, other_number: int) -> int: + """(unsigned right shift).""" + return _to_uint32(number) >> (other_number & 31) + + +def _little_endian_signed_word(byte_values: list, off: int) -> int: + """Return a little-endian four-byte word with each byte sign-extended.""" + def _sign_extend_byte(number: int) -> int: + return number - 256 if number > 127 else number + return (_sign_extend_byte(byte_values[off]) + (_sign_extend_byte(byte_values[off + 1]) << 8) + + (_sign_extend_byte(byte_values[off + 2]) << 16) + (_sign_extend_byte(byte_values[off + 3]) << 24)) + + +def _utf8_bytes_from_utf16_units(text: str) -> list[int]: + """Encode by walking UTF-16 code units, preserving lone surrogates.""" + raw = text.encode("utf-16-le", "surrogatepass") + units = [raw[index] | (raw[index + 1] << 8) for index in range(0, len(raw), 2)] + output_bytes: list[int] = [] + index = 0 + while index < len(units): + value = units[index] + if value < 128: + output_bytes.append(value) + elif value < 2048: + output_bytes.append((value >> 6) | 192) + output_bytes.append((value & 63) | 128) + else: + if ( + (value & 0xFC00) == 0xD800 + and index + 1 < len(units) + and (units[index + 1] & 0xFC00) == 0xDC00 + ): + index += 1 + value = 0x10000 + ((value & 1023) << 10) + (units[index] & 1023) + output_bytes.append((value >> 18) | 240) + output_bytes.append(((value >> 12) & 63) | 128) + else: + output_bytes.append((value >> 12) | 224) + output_bytes.append(((value >> 6) & 63) | 128) + output_bytes.append((value & 63) | 128) + index += 1 + return output_bytes + + +def _jenkins_mix(mix_state: list) -> int: + """Jenkins lookup2 mix over the 3-word state ``[a, b, c]``.""" + secondary_item, candidate_item, reference_item = mix_state + secondary_item = _int32_xor(secondary_item - candidate_item - reference_item, _uint32_right_shift(reference_item, 13)) + candidate_item = _int32_xor(candidate_item - reference_item - secondary_item, _int32_left_shift(secondary_item, 8)) + reference_item = reference_item - secondary_item + reference_item = _int32_xor(reference_item - candidate_item, _uint32_right_shift(candidate_item, 13)) + secondary_item = secondary_item - candidate_item + secondary_item = secondary_item - reference_item + secondary_item = _int32_xor(secondary_item, _uint32_right_shift(reference_item, 12)) + candidate_item = _int32_xor(candidate_item - reference_item - secondary_item, _int32_left_shift(secondary_item, 16)) + reference_item = reference_item - secondary_item + reference_item = _int32_xor(reference_item - candidate_item, _uint32_right_shift(candidate_item, 5)) + secondary_item = secondary_item - candidate_item + secondary_item = secondary_item - reference_item + secondary_item = _int32_xor(secondary_item, _uint32_right_shift(reference_item, 3)) + candidate_item = _int32_xor(candidate_item - reference_item - secondary_item, _int32_left_shift(secondary_item, 10)) + reference_item = reference_item - secondary_item + reference_item = _int32_xor(reference_item - candidate_item, _uint32_right_shift(candidate_item, 15)) + mix_state[0], mix_state[1], mix_state[2] = secondary_item, candidate_item, reference_item + return reference_item + + +def jenkins_hash(text: str) -> int: + """Encode text through the package UTF-16/UTF-8 byte path, then run Jenkins lookup2.""" + byte_values = _utf8_bytes_from_utf16_units(text) + count_item = len(byte_values) + mix_state = [-1640531527, -1640531527, 314159265] # 0x9E3779B9, 0x9E3779B9, seed + off = 0 + entry_item = count_item + while entry_item >= 12: + mix_state[0] = mix_state[0] + _little_endian_signed_word(byte_values, off) + mix_state[1] = mix_state[1] + _little_endian_signed_word(byte_values, off + 4) + mix_state[2] = mix_state[2] + _little_endian_signed_word(byte_values, off + 8) + _jenkins_mix(mix_state) + entry_item -= 12 + off += 12 + mix_state[2] = mix_state[2] + count_item + # Tail-byte mixing follows Jenkins lookup2's fall-through layout. + if entry_item >= 11: + mix_state[2] = mix_state[2] + _int32_left_shift(byte_values[off + 10], 24) + if entry_item >= 10: + mix_state[2] = mix_state[2] + ((byte_values[off + 9] & 255) << 16) + if entry_item >= 9: + mix_state[2] = mix_state[2] + ((byte_values[off + 8] & 255) << 8) + if entry_item >= 8: + mix_state[1] = mix_state[1] + _little_endian_signed_word(byte_values, off + 4) + mix_state[0] = mix_state[0] + _little_endian_signed_word(byte_values, off) + elif entry_item >= 4: + if entry_item >= 7: + mix_state[1] = mix_state[1] + ((byte_values[off + 6] & 255) << 16) + if entry_item >= 6: + mix_state[1] = mix_state[1] + ((byte_values[off + 5] & 255) << 8) + if entry_item >= 5: + mix_state[1] = mix_state[1] + (byte_values[off + 4] & 255) + mix_state[0] = mix_state[0] + _little_endian_signed_word(byte_values, off) + else: + if entry_item >= 3: + mix_state[0] = mix_state[0] + ((byte_values[off + 2] & 255) << 16) + if entry_item >= 2: + mix_state[0] = mix_state[0] + ((byte_values[off + 1] & 255) << 8) + if entry_item >= 1: + mix_state[0] = mix_state[0] + (byte_values[off] & 255) + return _jenkins_mix(mix_state) diff --git a/pageindex/flash/tokens/token_types.py b/pageindex/flash/tokens/token_types.py new file mode 100644 index 000000000..56e88c005 --- /dev/null +++ b/pageindex/flash/tokens/token_types.py @@ -0,0 +1,243 @@ +"""Token types, anchors, and token-view utilities.""" + +from __future__ import annotations + +from typing import Any, Iterable, Iterator, Optional + +from ..model import ( + _strip_diacritics, + avg_char_width2, + intervals_overlap, + to_number, + rect_union, + EMPTY_RECT, + avg_char_width, + Line, + char_category, + is_word_category, + is_punct_category, + letter_count, + punct_count, + info_weight, + Block, +) + + +# --------------------------------------------------------------------------- # +# Character-category transition table # +# --------------------------------------------------------------------------- # + + +# Map 12 character categories down to script-family buckets used by statistics. +SCRIPT_FAMILY_MAP = [0, 1, 2, 2, 2, 2, 3, 4, 5, 6, 7, 7, 8] + + +def _build_gap_tolerance_grid() -> list[list[float]]: + """Return the fractional gap tolerance for adjacent character categories.""" + token_value = [[0.0] * 12 for _ in range(12)] + # Small punctuation-to-mark transition weights. + for candidate_item in (1, 2, 3, 4): + token_value[candidate_item][5] = 0.16 + token_value[candidate_item][6] = 0.16 + token_value[3][2] = 0.1 + token_value[6][2] = 0.1 + token_value[6][3] = 0.1 + token_value[8][2] = 0.1 + token_value[8][3] = 0.1 + return token_value + + +GAP_TOLERANCE_GRID = _build_gap_tolerance_grid() + + +def can_extend_token(number: int, other_number: int, candidate_text: str) -> bool: + """Return whether the current token can extend with ``candidate_text``.""" + from ..stats import char_script_bucket + if other_number == 4 and char_script_bucket(candidate_text) == 5: + return False + if other_number == number and not is_punct_category(other_number): + return True + if is_word_category(number) and is_word_category(other_number): + return True + return False + + +# --------------------------------------------------------------------------- # +# Token span anchors. +# --------------------------------------------------------------------------- # + + +class TokenAnchor: + """Cross-line anchor range attached to a token.""" + + __slots__ = ("line", "anchor_span", "start_offset", "primary_slot") + + def __init__(self, line: Line, anchor_span_value, start_offset_value: int, next_number: int): + self.line = line + self.anchor_span = anchor_span_value + self.start_offset = start_offset_value + self.primary_slot = next_number + + +def last_token_anchor(token: "Token") -> TokenAnchor: + """Return the token's last cross-line anchor entry.""" + return token.anchor_ranges[-1] + + +def first_anchor_span(token: "Token"): + """Return the anchor span from the token's first cross-line entry.""" + return token.anchor_ranges[0].anchor_span + + +# --------------------------------------------------------------------------- # +# Token kind predicates # +# --------------------------------------------------------------------------- # + + +def is_char_token(token: "Token") -> bool: + """Return True for digit or letter tokens.""" + return token.type == 1 or token.type == 2 + + +def is_word_token(token: "Token") -> bool: + """Return True for merged word-like tokens: word, number-word, or symbolic token kinds.""" + return token.type in (3, 4, 5) + + +def is_trimmable_token(token: "Token") -> bool: + """Return True for word, number-word, or colon tokens that can be trimmed from phrase edges.""" + return token.type == 3 or token.type == 4 or token.str == ":" + + +def token_numeric_value(token: "Token") -> float: + """numeric value of token, NaN if non-numeric.""" + return to_number(token.str) + + +# --------------------------------------------------------------------------- # +# Token # +# --------------------------------------------------------------------------- # + + +class Token: + """One token. It stores the token kind, raw text, contributing line/span anchors, bracket attachment flag, and first/last character categories.""" + + __slots__ = ("type", "str", "anchor_ranges", "boundary_slot", "primary_slot", "secondary_slot") + + def __init__(self, type_: int, candidate_text: str, anchor_ranges_value: list[TokenAnchor], boundary_flag: bool, previous_number: int, limit_number: int): + self.type = type_ + self.str = candidate_text + self.anchor_ranges = anchor_ranges_value + self.boundary_slot = boundary_flag + self.primary_slot = previous_number + self.secondary_slot = limit_number + + def line(self) -> Line: + """Line of the first origin-span back-reference.""" + return self.anchor_ranges[0].line + + def __repr__(self) -> str: # diagnostic + return f"" + + +# --------------------------------------------------------------------------- # +# Directional token view # +# --------------------------------------------------------------------------- # + + +class TokenView: + """Sliceable, directional view over a token array. Supports forward / reverse iteration via ``dir`` = +1 / -1. ``slice`` and ``reverse`` produce new views without copying. """ + + __slots__ = ("primary_slot", "start", "end", "dir", "length") + + def __init__(self, other_tokens: list[Token], start: int, end: int, dir_: int): + self.primary_slot = other_tokens + self.start = start + self.end = end + self.dir = dir_ + self.length = (end - start) // dir_ if dir_ != 0 else 0 + + def __iter__(self) -> Iterator[Token]: + secondary_item = self.start + while secondary_item != self.end: + yield self.primary_slot[secondary_item] + secondary_item += self.dir + + def token_at(self, other_number: int) -> Optional[Token]: + if other_number < 0 or other_number >= self.length: + return None + return self.primary_slot[self.start + other_number * self.dir] + + def __getitem__(self, other_number: int) -> Optional[Token]: + return self.token_at(other_number) + + def __len__(self) -> int: + return self.length + + def __bool__(self) -> bool: + return self.length > 0 + + def __str__(self) -> str: + parts: list[str] = [] + for token in self: + parts.append(token.str) + if token.boundary_slot: + parts.append(" ") + return "".join(parts) + + def slice(self, other_number: int = 0, candidate_number: int = 0) -> "TokenView": + """Bounds-clamped directional slice. Args follow Unicode-compatible semantics: a > 0 -> from index a a < 0 -> from end-relative a = 0 -> from start b > 0 -> to index b b < 0 -> end-relative b = 0 -> to end """ + if other_number > 0: + slice_start = self.start + other_number * self.dir + elif other_number < 0: + slice_start = self.end + other_number * self.dir + else: + slice_start = self.start + if slice_start * self.dir < self.start * self.dir: + slice_start = self.start + if slice_start * self.dir > self.end * self.dir: + slice_start = self.end + if candidate_number > 0: + slice_end = self.start + candidate_number * self.dir + elif candidate_number < 0: + slice_end = self.end + candidate_number * self.dir + else: + slice_end = self.end + if slice_end * self.dir < slice_start * self.dir: + slice_end = slice_start + if slice_end * self.dir > self.end * self.dir: + slice_end = self.end + return TokenView(self.primary_slot, slice_start, slice_end, self.dir) + + def reverse(self) -> "TokenView": + return TokenView(self.primary_slot, self.end - self.dir, self.start - self.dir, -self.dir) + + def to_string(self) -> str: + return str(self) + + +def wrap_tokens(tokens: list[Token]) -> TokenView: + """Wrap a list of tokens as a forward token view.""" + return TokenView(tokens, 0, len(tokens), 1) + + +def enumerate_tokens(tokens: TokenView) -> Iterator[dict]: + """Enumerate a token view yielding indexed token records.""" + token = tokens.primary_slot + start = tokens.start + end = tokens.end + step = tokens.dir + cursor = start + while cursor != end: + yield {"index": (cursor - start) // step, "token": token[cursor]} + cursor += step + + +def first_token(tokens: TokenView) -> Optional[Token]: + """Return the first token, or None.""" + return tokens.primary_slot[tokens.start] if tokens.length > 0 else None + + +def last_token(tokens: TokenView) -> Optional[Token]: + """Return the last token, or None.""" + return tokens.primary_slot[tokens.end - tokens.dir] if tokens.length > 0 else None diff --git a/pageindex/flash/tokens/tokenizer.py b/pageindex/flash/tokens/tokenizer.py new file mode 100644 index 000000000..1246f0b58 --- /dev/null +++ b/pageindex/flash/tokens/tokenizer.py @@ -0,0 +1,271 @@ +"""Line tokenization into word, char, and number tokens.""" + +from __future__ import annotations + +import unicodedata +from typing import Any, Iterable, Iterator, Optional + +from ..model import ( + _strip_diacritics, + avg_char_width2, + intervals_overlap, + to_number, + rect_union, + EMPTY_RECT, + avg_char_width, + Line, + char_category, + is_word_category, + is_punct_category, + letter_count, + punct_count, + info_weight, + Block, +) + +from .token_types import ( + SCRIPT_FAMILY_MAP, + GAP_TOLERANCE_GRID, + can_extend_token, + TokenAnchor, + last_token_anchor, + first_anchor_span, + Token, + TokenView, + wrap_tokens, +) + + +# --------------------------------------------------------------------------- # +# Line tokenizer state machine # +# --------------------------------------------------------------------------- # + + +class LineTokenizer: + """Line-tokenizer state machine with line/span anchors for reconstruction.""" + + __slots__ = ("tertiary_slot", "secondary_slot", "cache_slot", "auxiliary_slot", "option_slot", "marker_slot", "primary_slot", "previous_slot", "state_slot", "style_slot", "measure_slot") + + def __init__(self): + self.tertiary_slot: list[Token] = [] + self.secondary_slot = None # last anchor span + self.cache_slot: Optional[Line] = None + self.auxiliary_slot = -1 + self.option_slot = -1 + self.marker_slot: list[TokenAnchor] = [] + self.primary_slot = "" + self.previous_slot = False + self.state_slot = 0 # last-char category + self.style_slot = 0 # first-char category + self.measure_slot = 0 # type-hint accumulator + + # --- inner state ops -------------------------------------------------- + + def _close_anchor_range(self) -> None: + """Close the current anchor range into the in-flight token and reset offsets.""" + self.marker_slot.append(TokenAnchor(self.cache_slot, self.secondary_slot, self.auxiliary_slot, self.option_slot)) + self.auxiliary_slot = self.option_slot = -1 + + def _close_token(self, boundary_flag: bool) -> None: + """Close the in-flight token into the token list.""" + if self.auxiliary_slot >= 0: + self._close_anchor_range() + self.tertiary_slot.append(Token(self.measure_slot, self.primary_slot, self.marker_slot, boundary_flag, self.style_slot, self.state_slot)) + self.marker_slot = [] + self.primary_slot = "" + self.measure_slot = 0 + self.style_slot = 0 + self.state_slot = 0 + + def _accumulate_char(self, other_text: str, candidate_number: int) -> None: + """Append a character and update the in-flight token kind from the category map.""" + if len(self.primary_slot) == 1 and self.state_slot == 5: + # If the in-flight token is a single mark, attach it before the new + # character so combining marks bind to the following letter. + self.primary_slot = other_text + self.primary_slot + self.style_slot = candidate_number + else: + if not self.primary_slot: + self.style_slot = candidate_number + self.primary_slot += other_text + self.state_slot = candidate_number + cat = SCRIPT_FAMILY_MAP[candidate_number] + if self.measure_slot == 0: + self.measure_slot = cat + elif self.measure_slot == 1 and cat != 1: + self.measure_slot = 2 + self.previous_slot = False + + def _advance_char(self, other_text: str, candidate_number: int) -> None: + """Advance the tokenizer with one character. Whitespace sets the pending-boundary flag; non-whitespace either extends or closes the current token.""" + reference_item = char_category(other_text) + if reference_item == 10: + # whitespace + self.previous_slot = True + return + + if self.previous_slot and self.primary_slot: + # If the last non-whitespace category and the current category cannot + # belong to the same word-like token, close the current token. + + if not (reference_item == 5 and is_word_category(self.state_slot)): + self._close_token(True) + + # Soft-hyphen rejoin across lines: if there is no in-flight token, the + # current char is lowercase, and the previous tokens were a word plus + # "-" ending on another line, undo the split and continue that word. + if not self.primary_slot and len(self.tertiary_slot) >= 2 and reference_item == 3: + entry_item = self.tertiary_slot[-1] + token = self.tertiary_slot[-2] + if ( + token.secondary_slot == 3 + and not token.boundary_slot + and entry_item.str == "-" + and last_token_anchor(entry_item).line is not self.cache_slot + ): + self.tertiary_slot.pop() # drop "-" + entry_item = self.tertiary_slot.pop() # pop word + self.measure_slot = entry_item.type + self.primary_slot = entry_item.str + self.marker_slot = entry_item.anchor_ranges + self.style_slot = entry_item.primary_slot + self.state_slot = entry_item.secondary_slot + self.previous_slot = False + self._accumulate_char(other_text, reference_item) + self.auxiliary_slot = self.option_slot = candidate_number + return + + if self.primary_slot: + if can_extend_token(self.state_slot, reference_item, other_text): + self._accumulate_char(other_text, reference_item) + if self.auxiliary_slot < 0: + self.auxiliary_slot = candidate_number + self.option_slot = candidate_number + else: + self._close_token(False) + self._accumulate_char(other_text, reference_item) + self.auxiliary_slot = self.option_slot = candidate_number + else: + self._accumulate_char(other_text, reference_item) + self.auxiliary_slot = self.option_slot = candidate_number + + # --- public API ------------------------------------------------------- + + def add_line(self, other_line: Line) -> "LineTokenizer": + """Walk one line and append its token contribution.""" + line = self.tertiary_slot[-1] if self.tertiary_slot else None + if self.primary_slot: + # Close in-flight; a trailing hyphen can glue to the next line only + # when the previous token was not already bracket-attached. + self._close_token(self.primary_slot != "-" or line is None or line.boundary_slot) + self.cache_slot = other_line + + # Single-codepoint pending combining mark. + pending = None # type: Optional[Any] + for index in range(len(other_line.primary_slot)): + span = other_line.primary_slot[index] + if span.char_count() <= 0: + continue + # Drop solitary combining marks (last-character category is 5) + + if pending is None and span.char_count() == 1 and span.char_stats.secondary_slot == 5: + pending = span + continue + # Drop the bullet-then-content kerning glitch (layout branch: + # single-character token, previous category is 11, and next span overlaps horizontally) + + if ( + index + 1 < len(other_line.primary_slot) + and span.char_count() == 1 + and span.char_stats.secondary_slot == 11 + and span.left_edge() >= other_line.primary_slot[index + 1].left_edge() + and span.center_x() < other_line.primary_slot[index + 1].right_edge() + ): + continue + + if self.secondary_slot is not None and self.primary_slot: + # Decide whether the new span continues the same token + if ( + span.left_edge() <= self.secondary_slot.right_edge() + 0.1 * avg_char_width2(self.secondary_slot) + and ( + abs(span.bottom_edge() - self.secondary_slot.bottom_edge()) < 0.1 + or abs(span.center_y() - self.secondary_slot.center_y()) < 0.1 + ) + and self.secondary_slot.primary_slot == span.primary_slot + ): + # Continue: close the current cross-line anchor entry and switch anchor. + self._close_anchor_range() + self.secondary_slot = span + self.previous_slot = False + else: + gap_tolerance = (GAP_TOLERANCE_GRID[self.secondary_slot.char_stats.tertiary_slot][span.char_stats.secondary_slot] or 0.12) * avg_char_width(self.cache_slot) + close = ( + self.previous_slot + or abs(self.secondary_slot.bottom_edge() - span.bottom_edge()) > 1 + or span.left_edge() < self.secondary_slot.right_edge() - 1 + or span.left_edge() > self.secondary_slot.right_edge() + gap_tolerance + ) + self._close_token(close) + self.secondary_slot = span + else: + self.secondary_slot = span + + for char_index in range(len(span.text)): + char_value = span.text[char_index] + if ( + char_index == 0 + and pending is not None + and intervals_overlap(pending.left_edge(), pending.right_edge(), span.left_edge(), span.right_edge()) + ): + # Compose with the pending combining mark + combined = unicodedata.normalize("NFC", char_value + pending.state_slot[0]) + self._advance_char(combined[0], 0) + else: + self._advance_char(char_value, char_index) + pending = None + return self + + def tokens(self) -> TokenView: + """Finalize and return a token view.""" + if self.primary_slot: + self._close_token(True) + return wrap_tokens(self.tertiary_slot) + + +# --------------------------------------------------------------------------- # +# X(block) -- cached token list for a block # +# --------------------------------------------------------------------------- # + + +def tokenize_block(block: Block) -> TokenView: + """tokenize all lines of a block, cached on the block token cache.""" + if block.tokens_cache is not None: + return block.tokens_cache # type: ignore[return-value] + token = LineTokenizer() + for line in block.primary_slot: + token.add_line(line) + block.tokens_cache = token.tokens() # type: ignore[assignment] + return block.tokens_cache # type: ignore[return-value] + + +# --------------------------------------------------------------------------- # +# Utility helpers. +# --------------------------------------------------------------------------- # + + +def clamp_value(value: float, lower_bound: float, upper_bound: float) -> float: + """Clamp a value between lower and upper bounds. The lower bound wins when the bounds are inverted, and NaN propagates.""" + measure_item = upper_bound if upper_bound < value else value + return lower_bound if lower_bound > measure_item else measure_item + + +def is_superscript_adjacent(token: Token, other_token: Token) -> bool: + """Return whether the next token is a raised, shorter marker on the same line.""" + candidate_item = last_token_anchor(token).anchor_span + reference_item = first_anchor_span(other_token) + return ( + reference_item is not candidate_item + and last_token_anchor(token).line is other_token.line() + and reference_item.bbox_height() < candidate_item.bbox_height() + and reference_item.bottom_edge() > candidate_item.bottom_edge() + 0.1 * candidate_item.bbox_height() + ) diff --git a/pageindex/flash/tokens/tries.py b/pageindex/flash/tokens/tries.py new file mode 100644 index 000000000..1ec129b07 --- /dev/null +++ b/pageindex/flash/tokens/tries.py @@ -0,0 +1,336 @@ +"""Trie construction, matching, and token trimming utilities.""" + +from __future__ import annotations + +from typing import Any, Iterable, Iterator, Optional + +from ..model import ( + _strip_diacritics, + avg_char_width2, + intervals_overlap, + to_number, + rect_union, + EMPTY_RECT, + avg_char_width, + Line, + char_category, + is_word_category, + is_punct_category, + letter_count, + punct_count, + info_weight, + Block, +) + +from .token_types import ( + can_extend_token, + is_trimmable_token, + TokenView, + wrap_tokens, + enumerate_tokens, + first_token, + last_token, +) + + +# --------------------------------------------------------------------------- # +# Token trie matcher and builder. +# --------------------------------------------------------------------------- # + + +def _de_norm(text: str, case_fold: bool) -> str: + """Normalize trie keys by optional case folding, NFD decomposition, combining-mark stripping, and NFC recomposition. This strips diacritics without applying compatibility normalization.""" + return _strip_diacritics(text.lower() if case_fold else text) + + +class TrieConfig: + """Trie configuration: reverse-match mode and case-fold mode.""" + + __slots__ = ("primary_slot", "secondary_slot") + + def __init__(self): + self.primary_slot: bool = False + self.secondary_slot: bool = False + + +class BuiltTrie: + """Built trie wrapper containing the root node and a reverse-match flag.""" + + __slots__ = ("secondary_slot", "primary_slot") + + def __init__(self, primary_item: "TrieNode", candidate_flag: bool): + self.secondary_slot = primary_item # root node + self.primary_slot = candidate_flag # reverse-match flag + + +def set_reverse(primary_item: TrieConfig) -> TrieConfig: + """set reverse flag.""" + primary_item.primary_slot = True + return primary_item + + +def set_case_fold(primary_item: TrieConfig, other_flag: bool) -> TrieConfig: + """set case-fold flag.""" + primary_item.secondary_slot = other_flag + return primary_item + + +class TrieNode: + """- trie node.""" + + __slots__ = ("str", "depth", "primary_slot", "children", "dict_suffix_link", "failure_link", "is_terminal", "payload") + + def __init__(self, other_text: str, depth: int, case_fold: bool): + self.str = other_text + self.depth = depth + self.primary_slot = case_fold + self.children: dict[str, "TrieNode"] = {} + self.dict_suffix_link = None + self.failure_link: Optional["TrieNode"] = None + self.is_terminal = False + self.payload = None + + def normalize(self, other_text: str) -> str: + return _de_norm(other_text, self.primary_slot) + + +def trie_insert_step(node: TrieNode, other_text: str) -> TrieNode: + """walk one child, creating if absent.""" + key = node.normalize(other_text) + child = node.children.get(key) + if child is None: + child = TrieNode(key, node.depth + 1, node.primary_slot) + node.children[key] = child + return child + + +def trie_walk_step(node: TrieNode, other_text: str) -> TrieNode: + """Walk one child; if absent, fall back through failure links.""" + key = node.normalize(other_text) + child = node.children.get(key) + if child is not None: + return child + if node.failure_link is not None: + return trie_walk_step(node.failure_link, other_text) + return node + + +def aho_corasick_match(trie: BuiltTrie, tokens) -> Optional[dict]: + """Aho-Corasick walk over a trie. Returns the shortest earliest terminal match and its payload. Dictionary-suffix matches use the suffix depth for match length while retaining the current node payload, which is load-bearing for edge cases.""" + if isinstance(tokens, list): + tokens = wrap_tokens(tokens) + if trie.primary_slot: + tokens = tokens.reverse() + matched_tokens: Optional[TokenView] = None + matched_reverse = None + earliest_start = -1 + node: TrieNode = trie.secondary_slot # root node + for entry in enumerate_tokens(tokens): + index = entry["index"] + token = entry["token"] + node = trie_walk_step(node, token.str) + depth = node.depth if node.is_terminal else 0 + if depth > 0 and (earliest_start < 0 or index - depth + 1 <= earliest_start): + earliest_start = index - depth + 1 + matched_tokens = tokens.slice(earliest_start, index + 1) + matched_reverse = node.payload + if trie.primary_slot: + matched_tokens = matched_tokens.reverse() + kb_node = node.dict_suffix_link + kb_depth = kb_node.depth if kb_node is not None else 0 + if kb_depth > 0 and (earliest_start < 0 or index - kb_depth + 1 <= earliest_start): + earliest_start = index - kb_depth + 1 + matched_tokens = tokens.slice(earliest_start, index + 1) + matched_reverse = node.payload + + if trie.primary_slot: + matched_tokens = matched_tokens.reverse() + # Once a match exists and the current path start has moved past the + # earliest match start, no later token can produce an earlier match. + if earliest_start >= 0 and index - node.depth + 1 > earliest_start: + break + if matched_tokens is None: + return None + return {"tokens": matched_tokens, "payload": matched_reverse} + + +def aho_corasick_tokens(trie: BuiltTrie, tokens) -> Optional[TokenView]: + """Return only the matched token view from an Aho-Corasick match.""" + token = aho_corasick_match(trie, tokens) + return token["tokens"] if token is not None else None + + +class TrieBuilder: + """Trie builder context holding the root node and configuration.""" + + __slots__ = ("primary_slot", "secondary_slot") + + def __init__(self, query_value: TrieConfig): + self.primary_slot = TrieNode("", 0, query_value.secondary_slot) # root node + self.secondary_slot = query_value # the config + + +def _trie_insert_entry(builder: TrieBuilder, entry: str, payload: Optional[Any] = None) -> None: + """Insert one phrase into the trie after character-by-character tokenization. This keeps punctuation-attached phrases such as ``vol.`` and ``etc.`` aligned with document tokenization. The optional payload is stored only on an empty terminal payload slot.""" + node = builder.primary_slot + tokens: list[str] = [] + trie = "" + previous_category = 0 + for char in entry: + cat = char_category(char) + if cat == 10 or (trie and not can_extend_token(previous_category, cat, char)): + if trie: + tokens.append(trie) + trie = "" + if cat != 10: + trie += char + previous_category = cat + if trie: + tokens.append(trie) + if builder.secondary_slot.primary_slot: + tokens.reverse() + for tok in tokens: + node = trie_insert_step(node, tok) + node.is_terminal = True + # Payload assignment uses truthiness: falsy payloads are skipped, and falsy + # existing payloads are overwritten. In this package payloads are non-empty + # dictionary-like objects, so the truthiness contract is stable. + if payload and not node.payload: + node.payload = payload + + +def trie_bulk_insert(builder: TrieBuilder, entries, payload: Optional[Any] = None) -> None: + """Bulk-insert phrases into ``builder`` with a shared terminal payload.""" + for entry in entries: + _trie_insert_entry(builder, entry, payload) + + +def _trie_finalize(builder: TrieBuilder) -> BuiltTrie: + """Assign Aho-Corasick failure links and dictionary-suffix links with breadth-first traversal, then return a built trie wrapper.""" + from collections import deque + + root = builder.primary_slot + queue: deque = deque([root]) + while queue: + node = queue.popleft() + for child in node.children.values(): + queue.append(child) + # failure link: longest proper suffix that is a prefix in the trie + trie = node + while trie.failure_link is not None: + child.failure_link = trie.failure_link.children.get(trie.failure_link.normalize(child.str)) + if child.failure_link is not None: + break + trie = trie.failure_link + if child.failure_link is None: + child.failure_link = root + # dictionary-suffix link: nearest failure ancestor that is terminal + trie = child.failure_link + while trie is not None: + if trie.is_terminal: + child.dict_suffix_link = trie + break + trie = trie.failure_link + + return BuiltTrie(builder.primary_slot, builder.secondary_slot.primary_slot) + + +def build_trie(strings: Iterable[str], other_trie: Optional[TrieConfig] = None) -> BuiltTrie: + """Build a trie from a list of phrase strings.""" + if other_trie is None: + other_trie = TrieConfig() + builder = TrieBuilder(other_trie) + for trie in strings: + _trie_insert_entry(builder, trie) + return _trie_finalize(builder) + + +def trie_prefix_match(trie: BuiltTrie, tokens) -> Optional[TokenView]: + """Return the longest prefix match against the token trie.""" + # ``tokens`` may be a TokenView or a list; coerce. + if isinstance(tokens, list): + tokens = wrap_tokens(tokens) + if trie.primary_slot: + tokens = tokens.reverse() + + matched: Optional[TokenView] = None + node: TrieNode = trie.secondary_slot # root node + for entry in enumerate_tokens(tokens): + if not node.children: + break + index = entry["index"] + token = entry["token"] + next_node = node.children.get(node.normalize(token.str)) + if next_node is None: + break + node = next_node + if node.is_terminal: + slice_view = tokens.slice(0, index + 1) + if trie.primary_slot: + slice_view = slice_view.reverse() + matched = slice_view + return matched + + +def _trie_full_match(trie: BuiltTrie, tokens) -> bool: + """full-match check.""" + result = trie_prefix_match(trie, tokens) + if isinstance(tokens, list): + tokens_view = wrap_tokens(tokens) + else: + tokens_view = tokens + return result is not None and result.length == tokens_view.length + + +trie_full_match = _trie_full_match + + +# --------------------------------------------------------------------------- # +# Token-list strip helpers. +# --------------------------------------------------------------------------- # + + +def strip_trie_match(tokens: TokenView, other_trie: BuiltTrie) -> TokenView: + """Strip a matching keyword sequence from a token view.""" + trie = trie_prefix_match(other_trie, tokens) + if trie is None: + return tokens + if other_trie.primary_slot: + return tokens.slice(0, tokens.length - trie.length) + return tokens.slice(trie.length) + + +def strip_leading_if_in(tokens: TokenView, other_items: set) -> TokenView: + """Strip the leading token if its text is in the provided set.""" + first = first_token(tokens) + if tokens.length > 0 and first is not None and first.str in other_items: + return tokens.slice(1) + return tokens + + +# Six comma variants only, not general punctuation. +COMMA_CHARS: set[str] = {",", "﹐", ",", "、", "﹑", "、"} + + +def strip_trailing_comma(tokens: TokenView) -> TokenView: + """Strip a trailing comma token.""" + last = last_token(tokens) + if tokens.length > 0 and last is not None and last.str in COMMA_CHARS: + return tokens.slice(0, tokens.length - 1) + return tokens + + +def is_comma_token(token) -> bool: + """Return True when the token string is one of the supported comma variants.""" + return token is not None and token.str in COMMA_CHARS + + +def trim_trailing_punct(tokens: TokenView) -> TokenView: + """Trim trailing punctuation-like tokens.""" + end = tokens.length + while end > 0: + tok = tokens.token_at(end - 1) + if tok is None or not is_trimmable_token(tok): + break + end -= 1 + return tokens.slice(0, end) diff --git a/requirements.txt b/requirements.txt index ae92bc49f..c0b76deb1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,5 +2,8 @@ litellm==1.84.0 # openai-agents # optional: required for examples/agentic_vectorless_rag_demo.py pymupdf==1.26.4 PyPDF2==3.0.1 +pypdfium2==4.30.0 python-dotenv==1.2.2 pyyaml==6.0.2 +regex>=2024.0.0 +sortedcontainers==2.4.0 diff --git a/run_pageindex.py b/run_pageindex.py index 673439d89..7845a44d3 100644 --- a/run_pageindex.py +++ b/run_pageindex.py @@ -2,6 +2,7 @@ import os import json from pageindex import * +from pageindex.flash import page_index_flash from pageindex.page_index_md import md_to_tree from pageindex.utils import ConfigLoader @@ -28,7 +29,10 @@ help='Whether to add doc description to the doc') parser.add_argument('--if-add-node-text', type=str, default=None, help='Whether to add text to the node') - + + parser.add_argument('--flash', action='store_true', + help='Build the tree structure from PDF layout statistics, without an LLM. PDF only.') + # Markdown specific arguments parser.add_argument('--if-thinning', type=str, default='no', help='Whether to apply tree thinning for markdown (markdown only)') @@ -51,27 +55,32 @@ if not os.path.isfile(args.pdf_path): raise ValueError(f"PDF file not found: {args.pdf_path}") - # Process PDF file - user_opt = { - 'model': args.model, - 'toc_check_page_num': args.toc_check_pages, - 'max_page_num_each_node': args.max_pages_per_node, - 'max_token_num_each_node': args.max_tokens_per_node, - 'if_add_node_id': args.if_add_node_id, - 'if_add_node_summary': args.if_add_node_summary, - 'if_add_doc_description': args.if_add_doc_description, - 'if_add_node_text': args.if_add_node_text, - } - opt = ConfigLoader().load({k: v for k, v in user_opt.items() if v is not None}) + if args.flash: + print('Parsing PDF with PageIndex Flash...') + toc_with_page_number = page_index_flash(args.pdf_path) + else: + # Process PDF file + user_opt = { + 'model': args.model, + 'toc_check_page_num': args.toc_check_pages, + 'max_page_num_each_node': args.max_pages_per_node, + 'max_token_num_each_node': args.max_tokens_per_node, + 'if_add_node_id': args.if_add_node_id, + 'if_add_node_summary': args.if_add_node_summary, + 'if_add_doc_description': args.if_add_doc_description, + 'if_add_node_text': args.if_add_node_text, + } + opt = ConfigLoader().load({k: v for k, v in user_opt.items() if v is not None}) - # Process the PDF - toc_with_page_number = page_index_main(args.pdf_path, opt) + # Process the PDF + toc_with_page_number = page_index_main(args.pdf_path, opt) print('Parsing done, saving to file...') # Save results pdf_name = os.path.splitext(os.path.basename(args.pdf_path))[0] output_dir = './results' - output_file = f'{output_dir}/{pdf_name}_structure.json' + suffix = '_structure_flash' if args.flash else '_structure' + output_file = f'{output_dir}/{pdf_name}{suffix}.json' os.makedirs(output_dir, exist_ok=True) with open(output_file, 'w', encoding='utf-8') as f: From 77e838c45062eb2a3819e090251f234a51c907a7 Mon Sep 17 00:00:00 2001 From: Ray Date: Mon, 27 Jul 2026 20:51:09 +0800 Subject: [PATCH 02/23] Sort dicts imports in title scoring --- pageindex/flash/title/scoring.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pageindex/flash/title/scoring.py b/pageindex/flash/title/scoring.py index 5d5666299..7c2c47308 100644 --- a/pageindex/flash/title/scoring.py +++ b/pageindex/flash/title/scoring.py @@ -28,8 +28,8 @@ from ..tokens import is_superscript_adjacent, clamp_value, enumerate_tokens, jenkins_hash, trie_prefix_match, set_case_fold, TrieConfig, build_trie, tokenize_block, _de_norm, BuiltTrie, is_word_token from .dicts import ( - TITLE_LABEL_TRIE, INSTITUTION_WORDS, + TITLE_LABEL_TRIE, ) From c7243700c7aa37bad386a5878b1badc269fcc7a2 Mon Sep 17 00:00:00 2001 From: Ray Date: Mon, 27 Jul 2026 20:51:09 +0800 Subject: [PATCH 03/23] Correct output schema in README --- pageindex/flash/README.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/pageindex/flash/README.md b/pageindex/flash/README.md index fa313df4e..675c2acec 100644 --- a/pageindex/flash/README.md +++ b/pageindex/flash/README.md @@ -25,12 +25,18 @@ missing, non-PDF, encrypted, empty, or unreadable file. "doc_name": str, "doc_title": str, "structure": [ - {"title": str, "start_index": int, "end_index": int, "nodes": [...]} + { + "title": str, + "node_id": str, # 4-digit, zero-padded + "start_index": int, + "end_index": int, + "nodes": [...], # absent on leaf nodes + } ], } ``` -Page indexes are 1-based. `nodes` nests recursively. +Page indexes are 1-based. `nodes` nests the same shape recursively. ## Limits From 2cee169de9d79a0c772b759654f605995ac43d7f Mon Sep 17 00:00:00 2001 From: Ray Date: Thu, 30 Jul 2026 18:41:52 +0800 Subject: [PATCH 04/23] Sync PageIndex Flash from private branch --- pageindex/flash/api.py | 2 +- pageindex/flash/columns/__init__.py | 4 +- pageindex/flash/columns/gutters.py | 6 +- pageindex/flash/columns/splitting.py | 4 +- .../flash/heading_detection/neighbors.py | 6 +- .../flash/heading_detection/text_checks.py | 9 +- pageindex/flash/main.py | 22 +- pageindex/flash/model/span_line.py | 10 +- pageindex/flash/outline_assembly/assembly.py | 18 +- .../flash/parser_pdfium_charlevel/__init__.py | 3 + .../parser_pdfium_charlevel/char_extract.py | 112 ++++--- .../parser_pdfium_charlevel/cmap_parse.py | 45 ++- .../parser_pdfium_charlevel/code_walk.py | 20 +- .../parser_pdfium_charlevel/content_stream.py | 12 +- .../parser_pdfium_charlevel/font_unicode.py | 46 +-- .../flash/parser_pdfium_charlevel/geometry.py | 40 ++- .../flash/parser_pdfium_charlevel/merge.py | 25 +- .../parser_pdfium_charlevel/pdf_objects.py | 4 +- .../flash/parser_pdfium_charlevel/pipeline.py | 297 ++++++++++-------- .../flash/parser_pdfium_charlevel/remerge.py | 10 +- .../parser_pdfium_charlevel/unicode_apply.py | 6 +- pageindex/flash/parser_pdfium_parallel.py | 139 ++++++++ 22 files changed, 569 insertions(+), 271 deletions(-) create mode 100644 pageindex/flash/parser_pdfium_parallel.py diff --git a/pageindex/flash/api.py b/pageindex/flash/api.py index 5081c27f0..1620c9814 100644 --- a/pageindex/flash/api.py +++ b/pageindex/flash/api.py @@ -69,7 +69,7 @@ def _validate_pdf(pdf): def page_index_flash(pdf) -> dict: - """Build a PageIndex tree structure from a PDF using layout statistics, without an LLM. Args: pdf: path to a PDF file (``str`` or ``pathlib.Path``) or an in-memory binary stream (``io.BytesIO``). Returns: dict with keys ``doc_name``, ``doc_title`` and ``structure`` (a list of nested ``{"title", "start_index", "end_index", "nodes"}`` dicts; page indexes are 1-based). """ + """Build a PageIndex tree structure from a PDF using layout statistics, without an LLM. Args: pdf: path to a PDF file (``str`` or ``pathlib.Path``) or an in-memory binary stream (``io.BytesIO``). Returns: dict with keys ``doc_name``, ``doc_title``, ``structure`` (a list of nested ``{"title", "start_index", "end_index", "nodes"}`` dicts; page indexes are 1-based) and ``has_abstract_or_references_section`` (True when a top-level entry is an abstract or references heading). """ return extract_toc(_validate_pdf(pdf)) diff --git a/pageindex/flash/columns/__init__.py b/pageindex/flash/columns/__init__.py index c46663f3f..8dbc0ecfe 100644 --- a/pageindex/flash/columns/__init__.py +++ b/pageindex/flash/columns/__init__.py @@ -11,7 +11,9 @@ import math from typing import Optional -from ..model import Rect, rect_union, EMPTY_RECT, Line, info_weight, text_of_line, numbering_kind, numbering_value, _UNICODE_WHITESPACE_CLASS, _max_nan_propagating +from ..model import ( + Rect, rect_union, EMPTY_RECT, Line, info_weight, text_of_line, numbering_kind, numbering_value, _UNICODE_WHITESPACE_CLASS, _max_nan_propagating, _min_nan_propagating, +) # Detect TOC dot leaders ("... 5", "....3"). Gutter scoring rejects a split diff --git a/pageindex/flash/columns/gutters.py b/pageindex/flash/columns/gutters.py index 94b7d04e6..9ca9c41ac 100644 --- a/pageindex/flash/columns/gutters.py +++ b/pageindex/flash/columns/gutters.py @@ -5,7 +5,9 @@ import math from typing import Optional -from ..model import Rect, rect_union, EMPTY_RECT, Line, info_weight, text_of_line, numbering_kind, numbering_value, _UNICODE_WHITESPACE_CLASS, _max_nan_propagating +from ..model import ( + Rect, rect_union, EMPTY_RECT, Line, info_weight, text_of_line, numbering_kind, numbering_value, _UNICODE_WHITESPACE_CLASS, _max_nan_propagating, _min_nan_propagating, +) # Detect TOC dot leaders ("... 5", "....3"). Gutter scoring rejects a split @@ -62,7 +64,7 @@ def __init__(self, primary_item, secondary_item, candidate_item): # ``log2(0)`` would be -inf -- guard against empty input. self.tertiary_slot = math.floor(2 * math.log2(len(candidate_item))) if candidate_item else 0 self.state_slot = primary_item.bbox_width() / 6.0 - self.auxiliary_slot = max(0.5 * secondary_item.primary_slot, min(1.1 * (secondary_item.tertiary_slot - secondary_item.primary_slot), 3.0 * secondary_item.primary_slot)) + self.auxiliary_slot = _max_nan_propagating(0.5 * secondary_item.primary_slot, _min_nan_propagating(1.1 * (secondary_item.tertiary_slot - secondary_item.primary_slot), 3.0 * secondary_item.primary_slot)) self.option_slot = secondary_item.measure_slot self.measure_slot = 1.5 * secondary_item.primary_slot DOT_LEADER_RE = re_module.compile(r"([.][" + _UNICODE_WHITESPACE_CLASS + r"]*){5,}\Z") diff --git a/pageindex/flash/columns/splitting.py b/pageindex/flash/columns/splitting.py index 8d7647af5..018fa1ec8 100644 --- a/pageindex/flash/columns/splitting.py +++ b/pageindex/flash/columns/splitting.py @@ -5,7 +5,9 @@ import math from typing import Optional -from ..model import Rect, rect_union, EMPTY_RECT, Line, info_weight, text_of_line, numbering_kind, numbering_value, _UNICODE_WHITESPACE_CLASS, _max_nan_propagating +from ..model import ( + Rect, rect_union, EMPTY_RECT, Line, info_weight, text_of_line, numbering_kind, numbering_value, _UNICODE_WHITESPACE_CLASS, _max_nan_propagating, _min_nan_propagating, +) from .gutters import ( SweepEvent, diff --git a/pageindex/flash/heading_detection/neighbors.py b/pageindex/flash/heading_detection/neighbors.py index 55e461332..db06778fb 100644 --- a/pageindex/flash/heading_detection/neighbors.py +++ b/pageindex/flash/heading_detection/neighbors.py @@ -91,7 +91,11 @@ def __init__(self, page): if 0 <= index < self.secondary_slot: marked[index] = True recent_height: list[int] = [-1] * self.secondary_slot # most recent body block height at bucket - recent_block_index: list[int] = [-1] * self.secondary_slot # most-recent block V (j-direction) + # Reads past the end of this list must behave like an unset slot: -1 is + # falsy at the ``>= 0`` tests below just as a missing entry is, and a + # write to it extends the list. On a degenerate page with zero buckets + # every clamped index is 0, so one slot reproduces that growth. + recent_block_index: list[int] = [-1] * max(self.secondary_slot, 1) # most-recent block V (j-direction) recent: list[Optional[Block]] = [None] * self.secondary_slot # most-recent block at bucket pending: list[list[int]] = [[] for _ in range(self.secondary_slot)] # pending V's per bucket diff --git a/pageindex/flash/heading_detection/text_checks.py b/pageindex/flash/heading_detection/text_checks.py index b740ab2e4..cd0203b8a 100644 --- a/pageindex/flash/heading_detection/text_checks.py +++ b/pageindex/flash/heading_detection/text_checks.py @@ -210,5 +210,12 @@ def letter_to_ordinal(tok_str: str) -> Optional[int]: """'a'/'A' -> 1, 'b' -> 2, ..., 'h' -> 8. None otherwise.""" if len(tok_str) != 1: return None - value = ord(tok_str[0].lower()) - ord("a") + 1 + # Only the FIRST UTF-16 code unit of the lowercased character counts: a + # case mapping that expands to several units (U+0130) contributes just its + # first, and an astral lowercase contributes its high surrogate. + low = tok_str[0].lower() + code_unit = ord(low[0]) + if code_unit > 0xFFFF: + code_unit = 0xD800 + ((code_unit - 0x10000) >> 10) + value = code_unit - 96 return value if 1 <= value <= 8 else None diff --git a/pageindex/flash/main.py b/pageindex/flash/main.py index 4fd28f55a..69cefd24c 100644 --- a/pageindex/flash/main.py +++ b/pageindex/flash/main.py @@ -21,9 +21,9 @@ from .labels import detect_captions, build_caption_regions, CaptionContext from .model import Rect, numbering_kind, block_text, deaccented_text, Block from .outline_assembly import ( - build_heading_from_block, is_landscape_or_empty, is_outline_valid, is_chapter_outline_valid, mark_outline_block_types, assemble_outline, compute_max_heading_gap, OutlineNode, outline_to_dict_tree, + build_heading_from_block, is_landscape_or_empty, is_outline_valid, is_chapter_outline_valid, mark_outline_block_types, assemble_outline, compute_max_heading_gap, has_table_or_prominent, OutlineNode, outline_to_dict_tree, ) -from .parser_pdfium_charlevel import parse_charlevel_meta +from .parser_pdfium_parallel import parse_charlevel_meta_parallel from .phases import assign_reading_order, PageView, process_page PageView = PageView # re-export for type hints from .stats import compute_doc_stats @@ -123,12 +123,13 @@ def page_by_block_lookup(pages, block) -> Optional[PageView]: def extract_toc( doc_handle: Union[str, Path, BytesIO], + workers: Optional[int] = None, ) -> dict: - """Run the full pipeline. Returns a dict shaped like:: { "doc_name": "...", "doc_title": "...", "structure": [ {"title": "...", "start_index": 1, "end_index": 3, "nodes": [...]}, ... ] } """ + """Run the full pipeline. Returns a dict shaped like:: { "doc_name": "...", "doc_title": "...", "structure": [ {"title": "...", "start_index": 1, "end_index": 3, "nodes": [...]}, ... ], "has_abstract_or_references_section": False } ``has_abstract_or_references_section`` is True when any TOP-LEVEL outline entry is an abstract-keyword heading or carries the prominent-heading flag (a references-keyword heading, plain or numbered). The near-empty bail and the valid-outline branch both report False. ``workers`` sets the process count for the per-page parallel parser: None = auto (CPU count - 1), 1 forces the sequential path; output is identical either way. """ # ----- 1) Parse PDF -> flat spans per page -------------------------- # per-page (view box, /Rotate) comes from the same engine (PDFium) that # produced the block coordinates, so the geometry frame is consistent. - parsed, page_meta = parse_charlevel_meta(doc_handle) + parsed, page_meta = parse_charlevel_meta_parallel(doc_handle, workers=workers) # ----- 2) Per-page layout classification ---------------------------------- # Heading coordinate projection uses the page viewport. @@ -164,7 +165,12 @@ def extract_toc( doc_name = Path(str(doc_handle)).name else: doc_name = "document.pdf" - return {"doc_name": doc_name, "doc_title": None, "structure": []} + return { + "doc_name": doc_name, + "doc_title": None, + "structure": [], + "has_abstract_or_references_section": False, + } # ----- 5) Classification: header / footer / watermark / TOC pages --- detect_header_footer(HeaderFooterContext(doc, 1)) # HEADER @@ -262,9 +268,13 @@ def extract_toc( outline_nodes = assemble_outline(doc, section_openers) # Validate the assembled outline. Structured outlines must cover enough # chapters; unstructured outlines are filtered by script and density gap. + # The abstract/references signal rides along with this gate: it is False on + # the valid-outline branch, and on the other branch it is read off the + # possibly-emptied list once the density filter has run. if is_outline_valid(doc, outline_nodes): if not is_chapter_outline_valid(doc, outline_nodes): outline_nodes = [] + has_abstract_or_references = False else: mark_outline_block_types(outline_nodes) page_count = len(doc.primary_slot) @@ -273,6 +283,7 @@ def extract_toc( and compute_max_heading_gap(outline_nodes, 1)["max_gap"] > (0.65 if doc.secondary_slot.tertiary_slot == 4 else 0.85) * page_count ): outline_nodes = [] + has_abstract_or_references = has_table_or_prominent(outline_nodes) if outline_nodes: structure = outline_to_dict_tree(outline_nodes, total_pages=len(pages)) else: @@ -288,6 +299,7 @@ def extract_toc( "doc_name": doc_name, "doc_title": doc_title, "structure": structure, + "has_abstract_or_references_section": has_abstract_or_references, } diff --git a/pageindex/flash/model/span_line.py b/pageindex/flash/model/span_line.py index 50b83215d..2c6379a05 100644 --- a/pageindex/flash/model/span_line.py +++ b/pageindex/flash/model/span_line.py @@ -26,9 +26,13 @@ # --------------------------------------------------------------------------- # -# Font-style detectors. -_bold_font_re = re.compile(r"(bold|timesb)", re.IGNORECASE) -_italic_font_re = re.compile(r"(ital|it$|i[1-9][0-9]*$|obliq)", re.IGNORECASE) +# Font-style detectors. Neither pattern is multiline or Unicode-aware: the end +# anchor binds at end of INPUT (Python's `$` would also match before a trailing +# newline, hence `\Z`), and case folding stays ASCII-only, so U+017F, U+0130 +# and U+0131 do not fold onto "s"/"i". The digit classes are spelled out, so +# the ASCII flag touches nothing else here. +_bold_font_re = re.compile(r"(bold|timesb)", re.IGNORECASE | re.ASCII) +_italic_font_re = re.compile(r"(ital|it\Z|i[1-9][0-9]*\Z|obliq)", re.IGNORECASE | re.ASCII) # Font-name canonicalization map. _font_name_aliases = { "timesnewroman": "Times", diff --git a/pageindex/flash/outline_assembly/assembly.py b/pageindex/flash/outline_assembly/assembly.py index 2f2da6ca1..f0dee4b12 100644 --- a/pageindex/flash/outline_assembly/assembly.py +++ b/pageindex/flash/outline_assembly/assembly.py @@ -265,15 +265,19 @@ def outline_to_dict_tree(outline_node_list: list[OutlineNode], total_pages: int) def _walk_nodes(items: list[OutlineNode]) -> list[dict]: result: list[dict] = [] for item in items: - # Title text is prefix tokens plus heading tokens. - prefix_tokens = item.heading.secondary_slot - token = item.heading.primary_slot - child = str(prefix_tokens) if prefix_tokens is not None else "" - node = str(token) if token is not None else "" - title = (child + " " + node) if child else node + # Title text is the numbering prefix plus the heading tokens, but + # the two are carried as separate fields and trimmed one by one, + # then rejoined with a single space and only for a non-empty + # prefix. A prefix's string form ends in a space after every + # space-flagged token, so trimming the parts separately is what + # keeps that space out of the join. # Trim with the Unicode WhiteSpace+LineTerminator set, not Python's # str.strip set: they differ on U+FEFF, U+0085, and U+001C-1F. - title = _trim_unicode_ws(title) + prefix_tokens = item.heading.secondary_slot + token = item.heading.primary_slot + child = _trim_unicode_ws(str(prefix_tokens)) if prefix_tokens is not None else "" + node = _trim_unicode_ws(str(token)) if token is not None else "" + title = (child + " " if child else "") + node if not title: if item.child_nodes: result.extend(_walk_nodes(item.child_nodes)) diff --git a/pageindex/flash/parser_pdfium_charlevel/__init__.py b/pageindex/flash/parser_pdfium_charlevel/__init__.py index 68ccffdd6..d354e3761 100644 --- a/pageindex/flash/parser_pdfium_charlevel/__init__.py +++ b/pageindex/flash/parser_pdfium_charlevel/__init__.py @@ -161,6 +161,9 @@ _remerge_vertical, ) from .pipeline import ( + _page_pass1, + _page_pass2, + _page_spans, parse_charlevel_meta, parse_charlevel, ) diff --git a/pageindex/flash/parser_pdfium_charlevel/char_extract.py b/pageindex/flash/parser_pdfium_charlevel/char_extract.py index 6d497c5ea..d55b4620c 100644 --- a/pageindex/flash/parser_pdfium_charlevel/char_extract.py +++ b/pageindex/flash/parser_pdfium_charlevel/char_extract.py @@ -29,10 +29,35 @@ def _extract_raw_chars(page, text_page) -> tuple[list[dict], list[dict]]: font_name_buffer = (ctypes.c_char * 256)() flags = ctypes.c_int(0) field = ctypes.c_float(0) + # Per-char FFI out-buffers and entry points, hoisted: each is overwritten + # by its call (buffers whose call result is unchecked are re-zeroed below, + # so a failed call reads back 0 exactly as a fresh buffer would). + char_origin_x = ctypes.c_double(0); char_origin_y = ctypes.c_double(0) + char_left_box = ctypes.c_double(0); char_right_box = ctypes.c_double(0) + value = ctypes.c_double(0); char_top_box = ctypes.c_double(0) + loose_box = pdfium_c.FS_RECTF(0, 0, 0, 0) + u32 = ctypes.c_uint32(0) + fs32 = ctypes.c_float(0) + byref = ctypes.byref + ox_ref = byref(char_origin_x); oy_ref = byref(char_origin_y) + l_ref = byref(char_left_box); r_ref = byref(char_right_box) + b_ref = byref(value); t_ref = byref(char_top_box) + loose_ref = byref(loose_box) + w_ref = byref(field) + flags_ref = byref(flags) + get_unicode = pdfium_c.FPDFText_GetUnicode + is_generated = pdfium_c.FPDFText_IsGenerated + get_char_origin = pdfium_c.FPDFText_GetCharOrigin + get_char_box = pdfium_c.FPDFText_GetCharBox + get_loose_box = pdfium_c.FPDFText_GetLooseCharBox + get_font_info = pdfium_c.FPDFText_GetFontInfo + get_glyph_width = pdfium_c.FPDFFont_GetGlyphWidth + js_is_ws = _is_whitespace + name_cache: dict[bytes, str] = {} raw_chars: list[dict] = [] last_obj: dict | None = None for index_value in range(count_item): - codepoint = pdfium_c.FPDFText_GetUnicode(text_page, index_value) + codepoint = get_unicode(text_page, index_value) if codepoint < 0: continue # u == 0 (PDFium found no unicode for the glyph) is KEPT as '\x00': @@ -41,7 +66,12 @@ def _extract_raw_chars(page, text_page) -> tuple[list[dict], list[dict]]: # textpage char carries normal geometry. Skipping it lost the char AND desynced # the unicode walk's object pairing around it. ch_str = chr(codepoint) - is_gen = bool(pdfium_c.FPDFText_IsGenerated(text_page, index_value)) + is_ws = js_is_ws(codepoint) + # FPDFText_IsGenerated returns a c_int: 1 generated, 0 real, -1 error. + # Only a POSITIVE 1 may mark a char generated -- the -1 has to read the + # same way here as it does in the page-mode unicode walk, or the two + # char sets disagree and that walk desyncs. + is_gen = is_generated(text_page, index_value) == 1 # PDFium inserts is_generated chars as layout placeholders for # Td/Tm jumps with no literal content-stream char (typically # " ", "\r", "\n"). Dropping them outright leaves an @@ -53,11 +83,11 @@ def _extract_raw_chars(page, text_page) -> tuple[list[dict], list[dict]]: # save_last_char without emitting, letting the next visible # glyph compute a tracking-size in-flow advance. Drop only # non-whitespace generated chars (very rare). - if is_gen and not _is_whitespace(codepoint): + if is_gen and not is_ws: continue - char_origin_x = ctypes.c_double(0) - char_origin_y = ctypes.c_double(0) - pdfium_c.FPDFText_GetCharOrigin(text_page, index_value, ctypes.byref(char_origin_x), ctypes.byref(char_origin_y)) + char_origin_x.value = 0.0; char_origin_y.value = 0.0 + get_char_origin(text_page, index_value, ox_ref, oy_ref) + ox_v = char_origin_x.value; oy_v = char_origin_y.value # Fetch char bbox first so we can use its center for the obj # lookup — origin alone fails when adjacent obj bboxes nearly @@ -65,17 +95,12 @@ def _extract_raw_chars(page, text_page) -> tuple[list[dict], list[dict]]: # with sub-pt gaps, where origin x falls inside the wrong obj's # tolerance window). Using bbox center gives unambiguous # containment. - char_left_box = ctypes.c_double(0); char_right_box = ctypes.c_double(0) - value = ctypes.c_double(0); char_top_box = ctypes.c_double(0) - pdfium_c.FPDFText_GetCharBox( - text_page, index_value, - ctypes.byref(char_left_box), ctypes.byref(char_right_box), - ctypes.byref(value), ctypes.byref(char_top_box), - ) + char_left_box.value = 0.0; char_right_box.value = 0.0; value.value = 0.0; char_top_box.value = 0.0 + get_char_box(text_page, index_value, l_ref, r_ref, b_ref, t_ref) char_left, char_right, char_top, char_bottom = char_left_box.value, char_right_box.value, char_top_box.value, value.value # Tight (ink) box center -> font-object disambiguation only. - center_x = (char_left + char_right) / 2 if char_right > char_left else char_origin_x.value - center_y = (char_top + char_bottom) / 2 if char_top > char_bottom else char_origin_y.value + center_x = (char_left + char_right) / 2 if char_right > char_left else ox_v + center_y = (char_top + char_bottom) / 2 if char_top > char_bottom else oy_v # Horizontal extent for the SPAN comes from the LOOSE char box (the # glyph's full advance cell), not the tight ink box. the PDF text-item # widths are advance-based; the ink box undershoots each glyph's right @@ -84,8 +109,9 @@ def _extract_raw_chars(page, text_page) -> tuple[list[dict], list[dict]]: # display-math gaps so the column detector mis-reads them as gutters # and splits a line ("E[x] = μ" -> "E[x]" fragment). Fall back to the # ink box if the loose box is unavailable/degenerate. - loose_box = pdfium_c.FS_RECTF(0, 0, 0, 0) - if (pdfium_c.FPDFText_GetLooseCharBox(text_page, index_value, ctypes.byref(loose_box)) + # (_loose is only READ when the call succeeded, so the hoisted struct + # never leaks a previous char's values.) + if (get_loose_box(text_page, index_value, loose_ref) and loose_box.right > loose_box.left): loose_left, loose_right = loose_box.left, loose_box.right # Vertical edges of the loose (advance-cell) box. For vertical- @@ -101,30 +127,35 @@ def _extract_raw_chars(page, text_page) -> tuple[list[dict], list[dict]]: # Character font size disambiguates overlapping objects, such as large # figure labels sharing a y range with smaller heading text. - char_fs_tp = pdfium_c.FPDFText_GetFontSize(text_page, index_value) # text-page and character-index lookup read the true per-char rendered size - # (FPDFText_GetMatrix, == text extraction font size) lazily, only to break a - # multi-object containment tie — see _find_obj_for_char. + # (FPDFText_GetMatrix) and the reported font size (FPDFText_GetFontSize) + # lazily, only to break a multi-object containment tie — see + # _find_obj_for_char. obj = _find_obj_for_char( - obj_index, center_x, center_y, tol=1.0, char_fs=char_fs_tp, text_page=text_page, char_idx=index_value + obj_index, center_x, center_y, tol=1.0, char_fs=None, text_page=text_page, char_idx=index_value ) if obj is None: obj = ( - _find_obj_for_char(obj_index, char_origin_x.value, char_origin_y.value, tol=1.0, - char_fs=char_fs_tp, text_page=text_page, char_idx=index_value) - or _find_obj_for_char(obj_index, char_origin_x.value, char_origin_y.value, tol=5.0, - char_fs=char_fs_tp, text_page=text_page, char_idx=index_value) + _find_obj_for_char(obj_index, ox_v, oy_v, tol=1.0, + char_fs=None, text_page=text_page, char_idx=index_value) + or _find_obj_for_char(obj_index, ox_v, oy_v, tol=5.0, + char_fs=None, text_page=text_page, char_idx=index_value) or last_obj ) if obj is None: continue last_obj = obj - name = pdfium_c.FPDFText_GetFontInfo(text_page, index_value, font_name_buffer, 256, ctypes.byref(flags)) - char_font_name = ( - bytes(font_name_buffer[:name]).decode("latin-1", errors="replace").rstrip("\x00") - if name > 1 else obj["font_name"] - ) + name = get_font_info(text_page, index_value, font_name_buffer, 256, flags_ref) + if name > 1: + raw_name = font_name_buffer[:name] + char_font_name = name_cache.get(raw_name) + if char_font_name is None: + char_font_name = raw_name.decode( + "latin-1", errors="replace").rstrip("\x00") + name_cache[raw_name] = char_font_name + else: + char_font_name = obj["font_name"] # Use baseline (oy) as bbox bottom and baseline + fs_eff as top. # the span anchoring rule uses matrix.f (= baseline y) for both top/ @@ -134,7 +165,7 @@ def _extract_raw_chars(page, text_page) -> tuple[list[dict], list[dict]]: # math glyphs). This is what the heading heuristics' tokenizer assumes when # checking |c1.C - c2.C| < 1 to decide whether two spans are on # the same line. - baseline_y = char_origin_y.value + baseline_y = oy_v char_top = baseline_y + obj["fs_eff"] # Capture the raw glyph advance now, while this page (and thus the # font handle) is alive. The fs_eff-dependent scaling happens later @@ -142,17 +173,16 @@ def _extract_raw_chars(page, text_page) -> tuple[list[dict], list[dict]]: # so deferring the call would require keeping every page open just to # keep font handles valid (PDFium frees the font when the page is # closed -> dangling handle). - pdfium_c.FPDFFont_GetGlyphWidth( - obj["font"], ctypes.c_uint32(codepoint), - ctypes.c_float(obj["fs_raw"]), ctypes.byref(field), - ) + u32.value = codepoint + fs32.value = obj["fs_raw"] + get_glyph_width(obj["font"], u32, fs32, w_ref) raw_chars.append({ - "i": index_value, "ch": chr(codepoint), "u": codepoint, + "i": index_value, "ch": ch_str, "u": codepoint, "is_gen": is_gen, - "is_ws": _is_whitespace(codepoint), + "is_ws": is_ws, "is_mn": _is_zero_width_diacritic(codepoint), "is_cf": _is_invisible_format_mark(codepoint), - "ox": char_origin_x.value, "oy": char_origin_y.value, + "ox": ox_v, "oy": oy_v, "left": loose_left, "right": loose_right, "top": char_top, "bottom": baseline_y, "box_top": char_top, @@ -175,7 +205,7 @@ def _accumulate_type3_extents(raw_chars: list[dict], acc: dict) -> None: bot = candidate_item["box_bottom"] - candidate_item["oy"] if top <= bot: # degenerate glyph box (text extraction skips d1 i==0) continue - _xref_key = ctypes.cast(item_value["font"], ctypes.c_void_p).value + _xref_key = item_value["font_key"] entry_item = acc.get(_xref_key) if entry_item is None: acc[_xref_key] = [top, bot] @@ -203,7 +233,7 @@ def _apply_type3_sizes(raw_chars: list[dict], size_by_font: dict) -> None: item_value = candidate_item["obj"] if item_value["fs_raw"] >= 1.5 or item_value["scale_y"] >= 1.5: continue - font_size_value = size_by_font.get(ctypes.cast(item_value["font"], ctypes.c_void_p).value) + font_size_value = size_by_font.get(item_value["font_key"]) if font_size_value: item_value["fs_eff"] = font_size_value candidate_item["top"] = candidate_item["oy"] + font_size_value @@ -276,7 +306,7 @@ def _finalize_chars(raw_chars: list[dict]) -> list[dict]: # emits a separate text item per font, so the body keeps fs=12 and # the code word fs=11.16 instead of the whole run collapsing to the # smaller fs_min. - "font_key": ctypes.cast(obj["font"], ctypes.c_void_p).value, + "font_key": obj["font_key"], "weight": obj["weight"], "obj": obj, # host text object (Tj/show-text) } diff --git a/pageindex/flash/parser_pdfium_charlevel/cmap_parse.py b/pageindex/flash/parser_pdfium_charlevel/cmap_parse.py index 26733d125..18a0eae0d 100644 --- a/pageindex/flash/parser_pdfium_charlevel/cmap_parse.py +++ b/pageindex/flash/parser_pdfium_charlevel/cmap_parse.py @@ -212,6 +212,11 @@ def codepoint_to_string(numeric_value: float) -> str: raise ValueError("code-point conversion non-integer") return chr(codepoint) + def is_int(numeric_value: float) -> bool: + # The integer test that guards the numeric-entry check and selects the + # destination branch rejects +-Infinity, NaN AND any fractional value. + return math.isfinite(numeric_value) and numeric_value == int(numeric_value) + def map_range_units(range_start: int, range_end: int, units: list[int]) -> None: # text extraction CMap.bf-range mapping : ``last byte`` is FIXED to # the ORIGINAL dst length-1; only THAT byte index is incremented. On @@ -260,34 +265,47 @@ def map_range_units(range_start: int, range_end: int, units: list[int]) -> None: src_end = _cmap_str_to_int(tokens[key_value + 1][1]) key_value += 2 if src_end - src_start > 0xFFFFFF: - # content stream tokenizer range-limit throw, contained by CMap parsing's - # catch: the oversized entry is dropped, the map survives. + # The range-limit throw is raised from INSIDE the bf-range + # mapping itself, i.e. from inside the call that CMap + # parsing wraps, so the rest of the block goes with it (the + # destination has already been lexed -- for an array, up to + # and including the "]"). if key_value < len(tokens) and tokens[key_value] == ("delim", "["): while key_value < len(tokens) and tokens[key_value] != ("delim", "]"): key_value += 1 key_value += 1 elif key_value < len(tokens) and tokens[key_value][0] in ("hex", "num"): key_value += 1 - continue + break if key_value < len(tokens) and tokens[key_value] == ("delim", "["): key_value += 1 code = src_start - while key_value < len(tokens) and tokens[key_value][0] in ("hex", "num"): + # The array form stores EVERY lexed object up to "]" or end + # of input; the UTF-16BE walk over a value that has no + # length (a name, an operator) runs zero times and yields + # the empty string. + while key_value < len(tokens) and tokens[key_value] != ("delim", "]"): if code <= src_end: dst_token = tokens[key_value] - out[code] = (_utf16be_units_to_str(list(dst_token[1])) - if dst_token[0] == "hex" else codepoint_to_string(dst_token[1])) + if dst_token[0] == "hex": + out[code] = _utf16be_units_to_str(list(dst_token[1])) + elif dst_token[0] == "num": + out[code] = codepoint_to_string(dst_token[1]) + else: + out[code] = "" code += 1 key_value += 1 - if key_value < len(tokens) and tokens[key_value] == ("delim", "]"): + if key_value < len(tokens): key_value += 1 elif key_value < len(tokens) and tokens[key_value][0] == "hex": units = list(tokens[key_value][1]) key_value += 1 map_range_units(src_start, src_end, units) - elif key_value < len(tokens) and tokens[key_value][0] == "num": + elif key_value < len(tokens) and tokens[key_value][0] == "num" and is_int(tokens[key_value][1]): # Integer destinations are one UTF-16 unit, then the normal - # increment walk applies. + # increment walk applies. A non-integer number is neither an + # integer nor a string nor "[", so it falls through to the + # `else` arm below. units = [int(tokens[key_value][1]) & 0xFFFF] key_value += 1 map_range_units(src_start, src_end, units) @@ -297,6 +315,11 @@ def map_range_units(range_start: int, range_end: int, units: list[int]) -> None: key_value += 1 while (key_value + 1 < len(tokens) and tokens[key_value][0] == "hex" and tokens[key_value + 1][0] == "num"): + if not is_int(tokens[key_value + 1][1]): + # The integer check throws -> the CMap parsing catch drops + # the rest of the block, map survives. + key_value += 2 + break out[_cmap_str_to_int(tokens[key_value][1])] = codepoint_to_string(tokens[key_value + 1][1]) key_value += 2 elif kind == "op" and val == "begincidrange": @@ -307,8 +330,10 @@ def map_range_units(range_start: int, range_end: int, units: list[int]) -> None: src_end = _cmap_str_to_int(tokens[key_value + 1][1]) start = tokens[key_value + 2][1] key_value += 3 + if not is_int(start): + break # the integer check precedes CID-range mapping: block dropped if src_end - src_start > 0xFFFFFF: - continue # range-limit, contained + break # CID-range range-limit: the block is dropped too for code in range(src_start, src_end + 1): out[code] = codepoint_to_string(start + (code - src_start)) else: diff --git a/pageindex/flash/parser_pdfium_charlevel/code_walk.py b/pageindex/flash/parser_pdfium_charlevel/code_walk.py index 09fd5b684..cd7315f75 100644 --- a/pageindex/flash/parser_pdfium_charlevel/code_walk.py +++ b/pageindex/flash/parser_pdfium_charlevel/code_walk.py @@ -70,21 +70,21 @@ def _resource_dict_xrefs(pdf_doc, owner_xref: int, sub: str) -> dict[bytes, int] def _page_show_codes( pdf_doc, page_idx: int, -) -> list[tuple[int | None, tuple[int, ...]]] | None: - """Every show op the page paints, in paint order, as ``(font_xref | None, charcode units)-- including text inside Form XObjects, spliced at their ``Do`` position with the XObject's own font redefinitions (span merger text-content extraction recurses the same way; PDFium's textpage flattens them inline). None when the page can't be read.""" +) -> list[tuple[int | None, tuple[int, ...], float]] | None: + """Every show op the page paints, in paint order, as ``(font_xref | None, charcode units, horizontal scale)-- including text inside Form XObjects, spliced at their ``Do`` position with the XObject's own font redefinitions (span merger text-content extraction recurses the same way; PDFium's textpage flattens them inline). The recursion runs on a CLONE of the live text state, so a form inherits both the active font and the horizontal scale; a Tz inside the form REPLACES it and never leaks back out. None when the page can't be read.""" page_xref = pdf_doc.page_xref(page_idx) def walk(stream: bytes, fonts_res: dict[bytes, int], - xobjs_res: dict[bytes, int], cur_font: int | None, + xobjs_res: dict[bytes, int], cur_font: int | None, cur_tz: float, visited: frozenset, depth: int, - out: list[tuple[int | None, tuple[int, ...]]]) -> None: + out: list[tuple[int | None, tuple[int, ...], float]]) -> None: if depth > 8: return - flush_ids, fonts, show_text_units, horizontal_scales, xobject_paints = _tokenize_show_operators(stream) + flush_ids, fonts, show_text_units, horizontal_scales, xobject_paints = _tokenize_show_operators(stream, cur_tz) dict_index = 0 for key_value in range(len(show_text_units) + 1): while dict_index < len(xobject_paints) and xobject_paints[dict_index][0] == key_value: - paint_position, xname, font_at_do = xobject_paints[dict_index] + paint_position, xname, font_at_do, tz_at_do = xobject_paints[dict_index] dict_index += 1 xobject_ref = xobjs_res.get(xname) # lexer names arrive #XX-parsed if xobject_ref is None or xobject_ref in visited: @@ -104,20 +104,20 @@ def walk(stream: bytes, fonts_res: dict[bytes, int], # as /Form has no stream; must not kill the whole page). continue walk(sub_stream, sub_fonts, sub_xobjs, - inherited, visited | {xobject_ref}, depth + 1, out) + inherited, tz_at_do, visited | {xobject_ref}, depth + 1, out) if key_value < len(show_text_units): resource_font_name = fonts[key_value] resource_font_index = (fonts_res.get(resource_font_name) if resource_font_name is not None else cur_font) - out.append((resource_font_index, show_text_units[key_value])) + out.append((resource_font_index, show_text_units[key_value], horizontal_scales[key_value])) try: - out: list[tuple[int | None, tuple[int, ...]]] = [] + out: list[tuple[int | None, tuple[int, ...], float]] = [] walk( pdf_doc[page_idx].read_contents(), _resource_dict_xrefs(pdf_doc, page_xref, "Font"), _resource_dict_xrefs(pdf_doc, page_xref, "XObject"), - None, frozenset(), 0, out, + None, 1.0, frozenset(), 0, out, # the initial text state starts at scale 1 ) return out except Exception: diff --git a/pageindex/flash/parser_pdfium_charlevel/content_stream.py b/pageindex/flash/parser_pdfium_charlevel/content_stream.py index 809a78c4e..5cd4e4195 100644 --- a/pageindex/flash/parser_pdfium_charlevel/content_stream.py +++ b/pageindex/flash/parser_pdfium_charlevel/content_stream.py @@ -57,19 +57,19 @@ def _tokenize_show_operators( - content_bytes: bytes, + content_bytes: bytes, init_tz: float = 1.0, ) -> tuple[list[int], list[bytes | None], list[tuple[int, ...]], list[float], - list[tuple[int, bytes, bytes | None]]]: - """Tokenize a PDF page content stream. For each text-showing operator, records the active flush scope, font redefinition name, raw charcode units, horizontal scaling, and Form XObject paint position. The tokenizer is deliberately tolerant of malformed operators: it skips bad or short operands, preserves unknown-command operands, and emits an empty string for a show operator with the wrong string operand type.""" + list[tuple[int, bytes, bytes | None, float]]]: + """Tokenize a PDF page content stream. For each text-showing operator, records the active flush scope, font redefinition name, raw charcode units, and horizontal scaling (starting at ``init_tz`` on stream entry, saved and restored by q/Q), plus every Form XObject paint position with the font and horizontal scaling live at that paint. The tokenizer is deliberately tolerant of malformed operators: it skips bad or short operands, preserves unknown-command operands, and emits an empty string for a show operator with the wrong string operand type.""" flush_ids: list[int] = [] fonts: list[bytes | None] = [] show_text_units: list[tuple[int, ...]] = [] - xobject_paints: list[tuple[int, bytes, bytes | None]] = [] + xobject_paints: list[tuple[int, bytes, bytes | None, float]] = [] horizontal_scales: list[float] = [] flush_id = 0 cur_font: bytes | None = None font_stack: list[bytes | None] = [] - cur_tz = 1.0 + cur_tz = init_tz tz_stack: list[float] = [] opnds: list[tuple[str, object]] = [] frames: list[tuple[str, list]] = [] # open [ / << collectors @@ -328,7 +328,7 @@ def push(kind: str, val: object) -> None: if tz_stack: cur_tz = tz_stack.pop() elif operator_token == b"Do" and opnds[0][0] == "name": - xobject_paints.append((len(flush_ids), opnds[0][1], cur_font)) # type: ignore[arg-type] + xobject_paints.append((len(flush_ids), opnds[0][1], cur_font, cur_tz)) # type: ignore[arg-type] elif operator_token == b"Tf": if opnds[0][0] == "name": cur_font = opnds[0][1] # type: ignore[assignment] diff --git a/pageindex/flash/parser_pdfium_charlevel/font_unicode.py b/pageindex/flash/parser_pdfium_charlevel/font_unicode.py index fd44fe1ec..0721d5eca 100644 --- a/pageindex/flash/parser_pdfium_charlevel/font_unicode.py +++ b/pageindex/flash/parser_pdfium_charlevel/font_unicode.py @@ -216,26 +216,32 @@ def _xref_key(number: int, other_text: str) -> tuple[str, str]: pdf_value_type, pdf_value = _xref_key(desc_xref, "ToUnicode") if pdf_value_type != "xref": pdf_value_type, pdf_value = _xref_key(xref, "ToUnicode") - if pdf_value_type != "xref": - # No ToUnicode: text extraction predefined collection Unicode-map construction maps Adobe-{GB1,CNS1,Japan1, - # Korea1} CIDSystemInfo through the shipped Adobe-XX-UCS2 bcmap - # (real unicode per cid) -- not implemented. Returning identity - # chr(cid) would actively CORRUPT PDFium's table-driven decode - # for that class, so keep the guarded None (PDFium output). - # Every other registry/ordering IS the heading heuristics identity fallback. - if desc_xref: - right_type, right_value_local = _xref_key(desc_xref, "CIDSystemInfo/Registry") - other_type, other_value_local = _xref_key(desc_xref, "CIDSystemInfo/Ordering") - reg = re.sub(r"[()\s]", "", right_value_local) if right_type != "null" else "" - ordering = re.sub(r"[()\s]", "", other_value_local) if other_type != "null" else "" - if reg == "Adobe" and ordering in ("GB1", "CNS1", "Japan1", "Korea1"): - return None - return 2, {} # identity Unicode map: unicode == chr(cid) - try: - return 2, _parse_tounicode_cmap( - pdf_doc.xref_stream(int(pdf_value.split()[0]))) - except Exception: - return 2, {} # ToUnicode parsing error path -> identity fallback + tu_map: dict[int, str] | None = None + if pdf_value_type == "xref": + try: + tu_map = _parse_tounicode_cmap( + pdf_doc.xref_stream(int(pdf_value.split()[0]))) + except Exception: + tu_map = None # ToUnicode parsing rejects -> no ToUnicode map + # The "font carries a ToUnicode map" flag is set only for a present, + # accepted and NON-EMPTY map. A missing, rejected or empty ToUnicode all + # leave it false, so all three take the composite branch below. + if tu_map: + return 2, tu_map + # No usable ToUnicode: predefined-collection Unicode-map construction + # maps Adobe-{GB1,CNS1,Japan1,Korea1} CIDSystemInfo through the shipped + # Adobe-XX-UCS2 bcmap (real unicode per cid) -- not implemented. + # Returning identity chr(cid) would actively CORRUPT PDFium's + # table-driven decode for that class, so keep the guarded None (PDFium + # output). Every other registry/ordering IS the identity fallback. + if desc_xref: + right_type, right_value_local = _xref_key(desc_xref, "CIDSystemInfo/Registry") + other_type, other_value_local = _xref_key(desc_xref, "CIDSystemInfo/Ordering") + reg = re.sub(r"[()\s]", "", right_value_local) if right_type != "null" else "" + ordering = re.sub(r"[()\s]", "", other_value_local) if other_type != "null" else "" + if reg == "Adobe" and ordering in ("GB1", "CNS1", "Japan1", "Korea1"): + return None + return 2, {} # identity Unicode map: unicode == chr(cid) pdf_value_type, pdf_value = _xref_key(xref, "BaseFont") base_font = pdf_value.lstrip("/") if pdf_value_type == "name" else "" diff --git a/pageindex/flash/parser_pdfium_charlevel/geometry.py b/pageindex/flash/parser_pdfium_charlevel/geometry.py index ada1bea77..e3a4b072d 100644 --- a/pageindex/flash/parser_pdfium_charlevel/geometry.py +++ b/pageindex/flash/parser_pdfium_charlevel/geometry.py @@ -89,14 +89,14 @@ def iter_text_objs(parent, anc_mtx, depth): continue # Bounds include the object's own matrix but not its ancestors'; map # the four corners into page space. - corners = [ - _xf_point(anc_mtx, corner_x, corner_y) - for corner_x in (bounds_left.value, bounds_right.value) for corner_y in (value.value, bounds_top.value) - ] - object_left = min(corner_x for corner_x, _ in corners) - object_right = max(corner_x for corner_x, _ in corners) - text = min(corner_y for _, corner_y in corners) - object_top = max(corner_y for _, corner_y in corners) + x00, y00 = _xf_point(anc_mtx, bounds_left.value, value.value) + x01, y01 = _xf_point(anc_mtx, bounds_left.value, bounds_top.value) + x10, y10 = _xf_point(anc_mtx, bounds_right.value, value.value) + x11, y11 = _xf_point(anc_mtx, bounds_right.value, bounds_top.value) + object_left = min(x00, x01, x10, x11) + object_right = max(x00, x01, x10, x11) + text = min(y00, y01, y10, y11) + object_top = max(y00, y01, y10, y11) ink_height = max(0.0, object_top - text) # text extraction folds Tfs (text font size) + FontMatrix into the text transform # so ``hypot(transform[2], transform[3])`` always gives the @@ -137,6 +137,9 @@ def iter_text_objs(parent, anc_mtx, depth): objects.append({ "font": font, + # Handle address as a hashable per-document font identity; computed + # once here so per-char consumers never re-cast. + "font_key": ctypes.cast(font, ctypes.c_void_p).value, "fs_raw": fs_raw, "scale_x": scale_x, "scale_y": scale_y, @@ -194,15 +197,21 @@ def _find_obj_for_char( char_fs: float | None = None, text_page=None, char_idx: int | None = None, ) -> dict | None: """Bbox containment lookup. When a char falls inside more than one text object, pick the candidate whose effective rendered size matches the char's true per-char matrix size from ``FPDFText_GetMatrix``. That folds Tfs and FontMatrix into the same glyph-to-font attribution used by the text-item reconstruction. This disambiguates overlapping objects such as a large figure-axis label drawn over a smaller heading, and avoids selecting tiny ghost objects that share the same raw textpage font size. Falls back to the PDFium ``fs_raw`` textpage font size and finally to smallest area.""" - cands: list[dict] = [] + first: dict | None = None + cands: list[dict] | None = None for item_value in obj_index.get(int(round(query_origin_y)), ()): if (item_value["l"] - tol) <= query_origin_x <= (item_value["r"] + tol) and\ (item_value["b"] - tol) <= query_origin_y <= (item_value["t"] + tol): - cands.append(item_value) - if not cands: + if first is None: + first = item_value + elif cands is None: + cands = [first, item_value] + else: + cands.append(item_value) + if first is None: return None - if len(cands) == 1: - return cands[0] + if cands is None: + return first char_render = ( _char_render_fs(text_page, char_idx) if text_page is not None and char_idx is not None else 0.0 @@ -213,6 +222,11 @@ def _find_obj_for_char( cands, key=lambda item_value: (abs(item_value["fs_eff"] - char_render), item_value["area"]), ) + if char_fs is None and text_page is not None and char_idx is not None: + # Deferred FPDFText_GetFontSize: only this rare branch (multi-candidate + # AND no per-char matrix) consumes it, so the caller no longer pays the + # FFI call on every char. + char_fs = pdfium_c.FPDFText_GetFontSize(text_page, char_idx) if char_fs is not None and char_fs > 0: # Sort by absolute fs diff first, then smallest area as tiebreak. return min( diff --git a/pageindex/flash/parser_pdfium_charlevel/merge.py b/pageindex/flash/parser_pdfium_charlevel/merge.py index dab9dd7b4..c7b31a656 100644 --- a/pageindex/flash/parser_pdfium_charlevel/merge.py +++ b/pageindex/flash/parser_pdfium_charlevel/merge.py @@ -68,6 +68,7 @@ def open_chunk(mapping: dict) -> None: horizontal_scale_factor = abs(mapping["obj"].get("tz", 1.0)) if not (horizontal_scale_factor > 0): horizontal_scale_factor = 1.0 + fs_x_tz = mapping["fs_x"] / horizontal_scale_factor chunk = { "str": [], "sign": sign, # +1 LTR, -1 RTL (signed x-axis) @@ -112,11 +113,11 @@ def open_chunk(mapping: dict) -> None: # (item initialization: Tz enters only the pen advance, not # text advance scale). PDFium folds Tz into the object matrix, so # fs_x carries it; divide the show-op's text horizontal scale back out. - "tracking": mapping["fs_x"] / horizontal_scale_factor * TRACKING_SPACE_FACTOR, - "not_a_space": mapping["fs_x"] / horizontal_scale_factor * NON_SPACE_GAP_FACTOR, - "negative": mapping["fs_x"] / horizontal_scale_factor * NEGATIVE_SPACE_FACTOR, - "flow_min": mapping["fs_x"] / horizontal_scale_factor * SPACE_IN_FLOW_MIN_FACTOR, - "flow_max": mapping["fs_x"] / horizontal_scale_factor * SPACE_IN_FLOW_MAX_FACTOR, + "tracking": fs_x_tz * TRACKING_SPACE_FACTOR, + "not_a_space": fs_x_tz * NON_SPACE_GAP_FACTOR, + "negative": fs_x_tz * NEGATIVE_SPACE_FACTOR, + "flow_min": fs_x_tz * SPACE_IN_FLOW_MIN_FACTOR, + "flow_max": fs_x_tz * SPACE_IN_FLOW_MAX_FACTOR, "height": mapping["fs"], # True once a real whitespace glyph follows the last visible glyph in # this chunk; gates whether an object boundary is a prose word-break @@ -213,6 +214,20 @@ def extend_chunk(mapping: dict, leading_space: bool) -> None: # text extraction text-item box accumulation char loop order (span merger+): # invisible format-mark classification is skipped entirely BEFORE the whitespace test. if text["is_cf"]: + # The format-mark skip sits ahead of the scaled-advance and + # char-spacing block, so the mark moves neither the text matrix nor + # the previous-position reference: the reference pen never sees it. + # PDFium's char origins DO include its advance, so carry the + # reading-direction reference past it; otherwise that advance + # reappears as a gap and the next glyph gets an in-flow or + # standalone " " with no counterpart. (Char spacing, also skipped + # here, is not separable from PDFium's origins.) With no chunk open + # the page's previous-position reference is still unset, so the + # position comparison is unconditionally true and there is no gap + # to correct. + if chunk is not None and chunk["prev_text_x"] is not None: + chunk["prev_text_x"] += chunk["sign"] * text.get("glyph_w", 0.0) + last_ref = (chunk["prev_text_x"], chunk["prev_oy"]) continue if text["is_ws"]: save_last_char(" ") diff --git a/pageindex/flash/parser_pdfium_charlevel/pdf_objects.py b/pageindex/flash/parser_pdfium_charlevel/pdf_objects.py index cf9b3bbb6..7b58d2a00 100644 --- a/pageindex/flash/parser_pdfium_charlevel/pdf_objects.py +++ b/pageindex/flash/parser_pdfium_charlevel/pdf_objects.py @@ -164,8 +164,8 @@ def close(self) -> None: self._reader.stream.close() except Exception: pass -_PDF_WHITESPACE_BYTES = bytes({0x20, 0x09, 0x0d, 0x0a, 0x0c, 0x00}) -_PDF_DELIMITER_BYTES = b"()<>[]{}/%" +_PDF_WHITESPACE_BYTES = frozenset({0x20, 0x09, 0x0d, 0x0a, 0x0c, 0x00}) +_PDF_DELIMITER_BYTES = frozenset(b"()<>[]{}/%") _PDF_STRING_ESCAPE_BYTES = {0x6E: 0x0A, 0x72: 0x0D, 0x74: 0x09, 0x62: 0x08, 0x66: 0x0C, diff --git a/pageindex/flash/parser_pdfium_charlevel/pipeline.py b/pageindex/flash/parser_pdfium_charlevel/pipeline.py index 0d26ac455..5a4caf9f0 100644 --- a/pageindex/flash/parser_pdfium_charlevel/pipeline.py +++ b/pageindex/flash/parser_pdfium_charlevel/pipeline.py @@ -49,6 +49,164 @@ ) +def _page_pass1(pdf, pdf_doc, page_idx: int, type3_ext: dict, font_map_cache: dict): + """Pass-1 body for ONE page: extract raw chars, tag objects, accumulate + Type-3 extents into ``type3_ext``. Returns ``(page, raw_chars, page_vb, + page_rot)``; the PAGE is returned still open — the caller owns closing it + (the sequential driver must keep every page open until pass 2's Type-3 + size lookups are done; see keep_pages in ``parse_charlevel_meta``).""" + page = pdf[page_idx] + text_page = page.get_textpage() + raw_chars, objects = _extract_raw_chars(page, text_page.raw) + try: + media_box_raw = _inherited_box(pdf_doc, page_idx, "MediaBox") if pdf_doc is not None else None + crop_box_raw = _inherited_box(pdf_doc, page_idx, "CropBox") if pdf_doc is not None else None + page_vb = _page_view_rect(page, media_box_raw, crop_box_raw) # (x0, y0, x1, y1) page space + except Exception: + page_vb = None # no box -> off-page test disabled + try: + page_rot = int(page.get_rotation()) # PDFium /Rotate (0/90/180/270) + except Exception: + page_rot = 0 + show_fonts: list[bytes | None] = [] + show_tzs: list[float] = [] + vert_names: set[bytes] = set() + if pdf_doc is not None and page_idx < pdf_doc.page_count: + try: + # show-op flush ids (q/Q flush scope) are no longer used -- the merge-id + # grouping was removed; only show_fonts (per-op font resname) + # feeds vertical tagging. + show_flush_ids, show_fonts, show_text_units, horizontal_scales, xobject_paints = _tokenize_show_operators( + pdf_doc[page_idx].read_contents()) + except Exception: + show_fonts = [] + vert_names = _page_vertical_resource_names(pdf_doc, page_idx) + # Tz follows the text into Form XObjects (the whole text state is + # cloned for the recursion), so the per-show-op horizontal-scale + # list has to come from the SAME form-descending walk as the codes: + # the page's own stream alone under-counts every form page and the + # ordinal gate below would then drop the tag for the whole page. + try: + show_codes = _page_show_codes(pdf_doc, page_idx) + if show_codes: + show_tzs = [horizontal_scale for _fx, _s, horizontal_scale in show_codes] + # Patch per-char unicode to span merger glyph Unicode where + # PDFium's decode differs (guarded: any failure keeps + # PDFium's output). + if raw_chars: + _apply_font_unicode( + text_page.raw, raw_chars, objects, show_codes, pdf_doc, + font_map_cache) + except Exception: + pass + _assign_vertical_tags(objects, show_fonts, vert_names) + _assign_show_tz(objects, show_tzs) + _accumulate_type3_extents(raw_chars, type3_ext) + text_page.close() + return page, raw_chars, page_vb, page_rot + + +def _page_pass2(raw_chars: list[dict], page_vb, size_by_font: dict) -> list[dict]: + """Pass-2 body for ONE page: apply the document-wide Type-3 sizes, + restore paint order, finalize glyph widths, run the text merger.""" + _apply_type3_sizes(raw_chars, size_by_font) + # text extraction emits glyphs in CONTENT-STREAM (paint) order; PDFium's textpage + # reorders whole segments page-wide (math-heavy page margin labels 'margin label' / + # 'Section N' arrive at a different point of the char stream than their + # show ops). obj["page_order"] is the object's stream position (objects + # parse sequentially, incl. the Form XObject walk), so sorting real + # glyphs by it restores span merger processing order for the merger. + # GENERATED chars (PDFium's synthetic layout whitespace -- no span merger + # counterpart, pure merger bookkeeping) keep no position of their own: + # their geometric obj lookup can land on the WRONG object (the + # multi-column "4 | Super | vision" heading puts the '4'->'S' gap + # space inside the 'vision' object, which would re-emit it mid-word as + # "Super vision"), so each one stays glued behind the real glyph that + # precedes it in textpage order. Character-level ordering's + # own items on the reordered pages. + keys: list[tuple] = [()] * len(raw_chars) + last_key = None + lead_gens: list[int] = [] + for key_value, candidate_item in enumerate(raw_chars): + if candidate_item["is_gen"]: + if last_key is None: + lead_gens.append(key_value) + else: + keys[key_value] = (last_key[0], last_key[1], 1, key_value) + else: + last_key = (candidate_item["obj"]["page_order"], candidate_item["i"]) + keys[key_value] = (last_key[0], last_key[1], 0, key_value) + for key_value in lead_gens: + keys[key_value] = (-1, -1, 1, key_value) + raw_chars[:] = [raw_chars[key_value] for key_value in sorted(range(len(raw_chars)), + key=keys.__getitem__)] + fin = _finalize_chars(raw_chars) + merged = _merge_text_items(fin, page_vb) + merged = _remerge_rotated(merged) # collapse cardinal-rotated per-glyph shards + merged = _remerge_vertical(merged) # collapse vertical-writing per-glyph shards + return _remerge_oblique(merged, fin) # oblique objects: inverse-rotation projection re-merge + + +def _page_spans(raw: list[dict]) -> list[Span]: + """Final emission for ONE page: merged chunks -> ``Span`` objects.""" + spans: list[Span] = [] + for item in raw: + # the heading heuristics pushes normalized glyph Unicode = the normalized-Unicode table[u] or u + + # per glyph, a WHOLE-string lookup. Each r["str"] piece is one glyph's + # unicode (or a synthesized space), so look up per piece -- a + # multi-codepoint ToUnicode value is left intact when the whole-string + # lookup misses, instead of decomposing a table-key char inside it. + # span merger: normalized glyph Unicode = RTL ligature reversal(the normalized-Unicode table + # [u] or u) -- the table lookup is then wrapped in RTL ligature reversal, which + + # reverses a multi-char Arabic/Hebrew ligature value (span merger + #). Apply per piece (each r["str"] piece is one glyph's unicode). + joined = "".join( + _reverse_if_rtl(_NORMALIZED_UNICODES.get(page_value, page_value)) for page_value in item["str"] # type: ignore[arg-type] + ) + # text extraction text-item flush -> bidirectional transform: the joined item + # text runs the bidi pass ON TOP of the per-glyph RTL ligature reversal + # above (both layers exist in span merger). Pass-through for LTR text + # and vertical items (dir 'ttb'). + joined = _apply_bidi_reordering(joined, -1, bool(item["obj"].get("vertical"))) + text = joined.translate(_DROP_CHARS) + if not text: + continue + # font_size = hypot(text matrix[2], text matrix[3]) + # taken once at the item's open glyph, i.e. the chunk's first-char + # fs. The merger breaks a chunk on any fs change (exact compare; + # see the font_key/fs guard above) and never lowers fs mid-chunk, so + # chunk["fs"] (set in open_chunk from the first char) is exactly + # that value. Emit it rather than the per-chunk minimum. + fs_emit = item["fs"] + spans.append( + Span( + bbox=Rect(item["left"], item["right"], item["top"], item["bottom"]), + text=text, + font_name_raw=item["font_name"], + font_size=fs_emit, + # the heading heuristics bold is name-regex only (the font-name bold regex, + # OR'd into the emitted span). span merger bold detector ignores the descriptor + # ForceBold flag and numeric weight, so we must NOT inject a + # weight-based bold here — that over-bolds Demi/Medium/bold math font + # faces (weight 665-675) text extraction treats as regular. + bold=False, + italic=False, + # Span skew score: P = (f[1]/f[0])² + (f[2]/f[3])² from the item + # transform (IEEE: cardinal rotation -> Inf, upright -> 0). + # The owning object's PDFium matrix has the same + # rotation/shear structure as span merger item transform. + # mtx0 = the FIRST glyph's object matrix (text extraction fixes the + # item transform at open); standalone fake-space items + # carry no mtx0 and fall back to their obj (= the previous + # glyph's object == text extraction previous glyph transform for that space). + skew=_compute_skew(item.get("mtx0") or item["obj"]["mtx"]), + ) + ) + return spans + + def parse_charlevel_meta(doc_handle: Union[str, Path, BytesIO]) -> tuple[list[list[Span]], list]: if isinstance(doc_handle, (str, Path)): pdf = pdfium.PdfDocument(str(doc_handle)) @@ -97,52 +255,12 @@ def parse_charlevel_meta(doc_handle: Union[str, Path, BytesIO]) -> tuple[list[li # per font redefinition). keep_pages = [] for page_idx in range(len(pdf)): - page = pdf[page_idx] + page, raw_chars, page_vb, page_rot = _page_pass1( + pdf, pdf_doc, page_idx, type3_ext, font_map_cache) keep_pages.append(page) - text_page = page.get_textpage() - raw_chars, objects = _extract_raw_chars(page, text_page.raw) - try: - media_box_raw = _inherited_box(pdf_doc, page_idx, "MediaBox") if pdf_doc is not None else None - crop_box_raw = _inherited_box(pdf_doc, page_idx, "CropBox") if pdf_doc is not None else None - page_vb = _page_view_rect(page, media_box_raw, crop_box_raw) # (x0, y0, x1, y1) page space - except Exception: - page_vb = None # no box -> off-page test disabled - try: - page_rot = int(page.get_rotation()) # PDFium /Rotate (0/90/180/270) - except Exception: - page_rot = 0 - show_fonts: list[bytes | None] = [] - show_tzs: list[float] = [] - vert_names: set[bytes] = set() - if pdf_doc is not None and page_idx < pdf_doc.page_count: - try: - # show-op flush ids (q/Q flush scope) are no longer used -- the merge-id - # grouping was removed; only show_fonts (per-op font resname) - # feeds vertical tagging. - show_flush_ids, show_fonts, show_text_units, show_tzs, xobject_paints = _tokenize_show_operators( - pdf_doc[page_idx].read_contents()) - except Exception: - show_fonts = [] - show_tzs = [] - vert_names = _page_vertical_resource_names(pdf_doc, page_idx) - # Patch per-char unicode to span merger glyph Unicode where PDFium's - # decode differs (guarded: any failure keeps PDFium's output). - if raw_chars: - try: - show_codes = _page_show_codes(pdf_doc, page_idx) - if show_codes: - _apply_font_unicode( - text_page.raw, raw_chars, objects, show_codes, pdf_doc, - font_map_cache) - except Exception: - pass - _assign_vertical_tags(objects, show_fonts, vert_names) - _assign_show_tz(objects, show_tzs) - _accumulate_type3_extents(raw_chars, type3_ext) per_page.append(raw_chars) page_view_boxes.append(page_vb) page_rotations.append(page_rot) - text_page.close() if pdf_doc is not None and pdf_doc is not doc_handle: try: pdf_doc.close() @@ -154,43 +272,7 @@ def parse_charlevel_meta(doc_handle: Union[str, Path, BytesIO]) -> tuple[list[li # then run text extraction text merger. raw_pages: list[list[dict]] = [] for page_view_index, raw_chars in enumerate(per_page): - _apply_type3_sizes(raw_chars, size_by_font) - # text extraction emits glyphs in CONTENT-STREAM (paint) order; PDFium's textpage - # reorders whole segments page-wide (math-heavy page margin labels 'margin label' / - # 'Section N' arrive at a different point of the char stream than their - # show ops). obj["page_order"] is the object's stream position (objects - # parse sequentially, incl. the Form XObject walk), so sorting real - # glyphs by it restores span merger processing order for the merger. - # GENERATED chars (PDFium's synthetic layout whitespace -- no span merger - # counterpart, pure merger bookkeeping) keep no position of their own: - # their geometric obj lookup can land on the WRONG object (the - # multi-column "4 | Super | vision" heading puts the '4'->'S' gap - # space inside the 'vision' object, which would re-emit it mid-word as - # "Super vision"), so each one stays glued behind the real glyph that - # precedes it in textpage order. Character-level ordering's - # own items on the reordered pages. - keys: list[tuple] = [()] * len(raw_chars) - last_key = None - lead_gens: list[int] = [] - for key_value, candidate_item in enumerate(raw_chars): - if candidate_item["is_gen"]: - if last_key is None: - lead_gens.append(key_value) - else: - keys[key_value] = (last_key[0], last_key[1], 1, key_value) - else: - last_key = (candidate_item["obj"]["page_order"], candidate_item["i"]) - keys[key_value] = (last_key[0], last_key[1], 0, key_value) - for key_value in lead_gens: - keys[key_value] = (-1, -1, 1, key_value) - raw_chars[:] = [raw_chars[key_value] for key_value in sorted(range(len(raw_chars)), - key=keys.__getitem__)] - fin = _finalize_chars(raw_chars) - merged = _merge_text_items(fin, page_view_boxes[page_view_index]) - merged = _remerge_rotated(merged) # collapse cardinal-rotated per-glyph shards - merged = _remerge_vertical(merged) # collapse vertical-writing per-glyph shards - merged = _remerge_oblique(merged, fin) # oblique objects: inverse-rotation projection re-merge - raw_pages.append(merged) + raw_pages.append(_page_pass2(raw_chars, page_view_boxes[page_view_index], size_by_font)) for page_handle in keep_pages: try: page_handle.close() @@ -200,62 +282,7 @@ def parse_charlevel_meta(doc_handle: Union[str, Path, BytesIO]) -> tuple[list[li out: list[list[Span]] = [] for raw in raw_pages: - spans: list[Span] = [] - for item in raw: - # the heading heuristics pushes normalized glyph Unicode = the normalized-Unicode table[u] or u - - # per glyph, a WHOLE-string lookup. Each r["str"] piece is one glyph's - # unicode (or a synthesized space), so look up per piece -- a - # multi-codepoint ToUnicode value is left intact when the whole-string - # lookup misses, instead of decomposing a table-key char inside it. - # span merger: normalized glyph Unicode = RTL ligature reversal(the normalized-Unicode table - # [u] or u) -- the table lookup is then wrapped in RTL ligature reversal, which - - # reverses a multi-char Arabic/Hebrew ligature value (span merger - #). Apply per piece (each r["str"] piece is one glyph's unicode). - joined = "".join( - _reverse_if_rtl(_NORMALIZED_UNICODES.get(page_value, page_value)) for page_value in item["str"] # type: ignore[arg-type] - ) - # text extraction text-item flush -> bidirectional transform: the joined item - # text runs the bidi pass ON TOP of the per-glyph RTL ligature reversal - # above (both layers exist in span merger). Pass-through for LTR text - # and vertical items (dir 'ttb'). - joined = _apply_bidi_reordering(joined, -1, bool(item["obj"].get("vertical"))) - text = joined.translate(_DROP_CHARS) - if not text: - continue - # font_size = hypot(text matrix[2], text matrix[3]) - # taken once at the item's open glyph, i.e. the chunk's first-char - # fs. The merger breaks a chunk on any fs change (exact compare; - # see the font_key/fs guard above) and never lowers fs mid-chunk, so - # chunk["fs"] (set in open_chunk from the first char) is exactly - # that value. Emit it rather than the per-chunk minimum. - fs_emit = item["fs"] - spans.append( - Span( - bbox=Rect(item["left"], item["right"], item["top"], item["bottom"]), - text=text, - font_name_raw=item["font_name"], - font_size=fs_emit, - # the heading heuristics bold is name-regex only (the font-name bold regex, - # OR'd into the emitted span). span merger bold detector ignores the descriptor - # ForceBold flag and numeric weight, so we must NOT inject a - # weight-based bold here — that over-bolds Demi/Medium/bold math font - # faces (weight 665-675) text extraction treats as regular. - bold=False, - italic=False, - # Span skew score: P = (f[1]/f[0])² + (f[2]/f[3])² from the item - # transform (IEEE: cardinal rotation -> Inf, upright -> 0). - # The owning object's PDFium matrix has the same - # rotation/shear structure as span merger item transform. - # mtx0 = the FIRST glyph's object matrix (text extraction fixes the - # item transform at open); standalone fake-space items - # carry no mtx0 and fall back to their obj (= the previous - # glyph's object == text extraction previous glyph transform for that space). - skew=_compute_skew(item.get("mtx0") or item["obj"]["mtx"]), - ) - ) - out.append(spans) + out.append(_page_spans(raw)) pdf.close() # Per-page viewport metadata (text extraction normalized page view = cropbox clamped to the # mediabox, via _page_view_rect, + /Rotate) parallel to out, so heading diff --git a/pageindex/flash/parser_pdfium_charlevel/remerge.py b/pageindex/flash/parser_pdfium_charlevel/remerge.py index 8904bae58..65459ba57 100644 --- a/pageindex/flash/parser_pdfium_charlevel/remerge.py +++ b/pageindex/flash/parser_pdfium_charlevel/remerge.py @@ -216,7 +216,7 @@ def _remerge_oblique(items: list[dict], fin_chars: list[dict]) -> list[dict]: def _start_vert_span(chunk: dict) -> dict: - """Create a vertical item from its first chunk. Vertical items use the rendered font size as width, accumulate height per glyph, and keep the first glyph's pen as the item transform. The span box therefore extends upward from the first pen even when glyphs visually run downward; this follows the same item-box convention used by horizontal and oblique items rather than the ink AABB.""" + """Create a vertical item from its first chunk. Vertical items use the rendered font size as width, accumulate height per glyph, and keep the first glyph's pen as the item transform. The item-to-span conversion reads the style's vertical flag and flips the sign of the height offset, so a vertical item's box runs DOWN from the pen where a horizontal one runs up. The span box reproduces that convention rather than the ink AABB.""" span = dict(chunk) span["str"] = list(chunk["str"]) span["font_tally"] = dict(chunk.get("font_tally", {})) @@ -229,8 +229,8 @@ def _close_vert_span(mapping: dict) -> dict: """Finalize the item merger-convention box of a vertical item.""" mapping["left"] = mapping["v_pen_x"] mapping["right"] = mapping["v_pen_x"] + mapping["fs"] - mapping["bottom"] = mapping["v_pen_y"] - mapping["top"] = mapping["v_pen_y"] + abs(mapping["v_height"]) + mapping["top"] = mapping["v_pen_y"] + mapping["bottom"] = mapping["v_pen_y"] - abs(mapping["v_height"]) return mapping @@ -270,7 +270,9 @@ def _merge_vertical_one(group: list[dict]) -> list[dict]: spans.append({ "str": [" "], "sign": 1, "obj": meta["obj"], "left": last_x, "right": last_x, # WIDTH 0 - "bottom": after, "top": after + abs(vertical_gap), + # A vertical style flips the height offset: the box runs DOWN + # from the previous pen, like _close_vert_span's. + "top": after, "bottom": after - abs(vertical_gap), "fs": meta["fs"], "fs_min": meta["fs"], "font_name": meta["font_name"], "font_key": meta["font_key"], "weight": meta["weight"], diff --git a/pageindex/flash/parser_pdfium_charlevel/unicode_apply.py b/pageindex/flash/parser_pdfium_charlevel/unicode_apply.py index e6356c87f..a7490bd20 100644 --- a/pageindex/flash/parser_pdfium_charlevel/unicode_apply.py +++ b/pageindex/flash/parser_pdfium_charlevel/unicode_apply.py @@ -19,7 +19,7 @@ def _apply_font_unicode( text_page, raw_chars: list[dict], objects: list[dict], - show_codes: list[tuple[int | None, tuple[int, ...]]], + show_codes: list[tuple[int | None, tuple[int, ...], float]], pdf_doc, map_cache: dict, ) -> None: @@ -71,7 +71,7 @@ def apply(patches: list[tuple[int, str]], drops: list[int], failed_windows: list[list[int]] = [] synth_sites: list[dict] = [] targets_by_object_index: dict[int, list[str] | None] = {} - for object_index, (obj, (font_index, encoded_text)) in enumerate(zip(objects, show_codes)): + for object_index, (obj, (font_index, encoded_text, _tz)) in enumerate(zip(objects, show_codes)): target_text_items = targets_for(font_index, encoded_text) targets_by_object_index[object_index] = target_text_items if target_text_items is None: @@ -296,7 +296,7 @@ def _run_window(window: list[int]) -> None: codepoint = pdfium_c.FPDFText_GetUnicode(text_page, char_index) seq.append((char_index, chr(codepoint) if codepoint > 0 else "\x00")) targets: list[str] = [] - for font_index, encoded_text in show_codes: + for font_index, encoded_text, _tz in show_codes: if not encoded_text: continue text_state = targets_for(font_index, encoded_text) diff --git a/pageindex/flash/parser_pdfium_parallel.py b/pageindex/flash/parser_pdfium_parallel.py new file mode 100644 index 000000000..f0d87b287 --- /dev/null +++ b/pageindex/flash/parser_pdfium_parallel.py @@ -0,0 +1,139 @@ +"""Per-page parallel driver for the charlevel parser. + +Wraps the UNMODIFIED per-page pipeline (``_page_pass1`` / ``_page_pass2`` / +``_page_spans``) in a process pool. PDFium's FFI is not thread-safe and its +handles are process-local, so parallelism uses processes, each opening its +own copy of the document. + +Parity contract: per-page processing depends on no cross-page state +except the document-wide identity-matrix Type-3 extent union. An empty union +makes ``_apply_type3_sizes`` a no-op, so per-page == whole-document exactly. +Workers run pass 1 + pass 2 per page assuming the union stays empty and +poison the run the moment any page accumulates an extent; the driver then +discards the parallel attempt and reruns the document on the sequential +path, which is the source of truth. Any other worker failure falls back the +same way, so this entry can only ever return sequential-identical output. + +Worker startup pays the full package import chain plus its own document +open; ``min_pages`` routes documents too small to amortize that to the +sequential path directly. +""" + +from __future__ import annotations + +import multiprocessing +import os +from concurrent.futures import ProcessPoolExecutor +from io import BytesIO +from pathlib import Path +from typing import Union + +import pypdfium2 as pdfium +import PyPDF2 as _pypdf2 # declared dependency (also imported by pageindex.utils/client) + +from .model import Span +from .parser_pdfium_charlevel import ( + parse_charlevel_meta, + _PdfDoc, + _page_pass1, + _page_pass2, + _page_spans, +) + +_MIN_PARALLEL_PAGES = 64 + + +class _Type3Detected(Exception): + """A page accumulated an identity-matrix Type-3 extent: the document + needs the cross-page font sizing only the sequential path performs.""" + + +# Per-worker state, set once by _init_worker in each spawned process. +_worker_pdf = None +_worker_pdf_doc = None +_worker_font_maps: dict = {} + + +def _init_worker(kind: str, payload) -> None: + global _worker_pdf, _worker_pdf_doc, _worker_font_maps + # Open the document exactly as parse_charlevel_meta does, including + # the guarded PyPDF2 open and its separate bytes copy. + if kind == "path": + _worker_pdf = pdfium.PdfDocument(payload) + else: + _worker_pdf = pdfium.PdfDocument(BytesIO(payload)) + _worker_pdf_doc = None + if _pypdf2 is not None: + try: + if kind == "path": + _worker_pdf_doc = _PdfDoc(_pypdf2.PdfReader(payload)) + else: + _worker_pdf_doc = _PdfDoc(_pypdf2.PdfReader(BytesIO(payload))) + except Exception: + _worker_pdf_doc = None + _worker_font_maps = {} + + +def _run_page(page_idx: int): + type3_ext: dict = {} + page, raw_chars, page_vb, page_rot = _page_pass1( + _worker_pdf, _worker_pdf_doc, page_idx, type3_ext, _worker_font_maps) + try: + if type3_ext: + raise _Type3Detected(page_idx) + merged = _page_pass2(raw_chars, page_vb, {}) + spans = _page_spans(merged) + finally: + page.close() + return spans, (page_vb, page_rot) + + +def parse_charlevel_meta_parallel( + doc_handle: Union[str, Path, BytesIO], + workers: int | None = None, + min_pages: int = _MIN_PARALLEL_PAGES, +) -> tuple[list[list[Span]], list]: + """Parallel-when-possible variant of ``parse_charlevel_meta``. + + Returns the same ``(pages, page_meta)`` with identical content for + every input. ``workers`` caps the pool size (default: CPU count - 1). + """ + if isinstance(doc_handle, (str, Path)): + src = ("path", str(doc_handle)) + elif isinstance(doc_handle, BytesIO): + src = ("bytes", doc_handle.getvalue()) + else: + # An already-open PdfDocument cannot be reopened per worker. + return parse_charlevel_meta(doc_handle) + + probe = pdfium.PdfDocument(BytesIO(src[1]) if src[0] == "bytes" else src[1]) + n_pages = len(probe) + probe.close() + + max_w = max(1, (os.cpu_count() or 2) - 1) + w = max(1, min(workers if workers is not None else max_w, max_w, n_pages)) + if w <= 1 or n_pages < min_pages: + return parse_charlevel_meta(doc_handle) + + executor = ProcessPoolExecutor( + max_workers=w, + mp_context=multiprocessing.get_context("spawn"), + initializer=_init_worker, + initargs=src, + ) + try: + results = list(executor.map(_run_page, range(n_pages))) + except Exception: + # _Type3Detected or any worker/pool failure. Cancel what is queued + # and rerun sequentially; in-flight pages finish in their workers + # and are discarded (separate processes, no shared PDFium state). + executor.shutdown(wait=False, cancel_futures=True) + return parse_charlevel_meta(doc_handle) + executor.shutdown() + + out = [spans for spans, _meta_entry in results] + meta = [meta_entry for _spans, meta_entry in results] + return out, meta + + +__all__ = ["parse_charlevel_meta_parallel"] From 0b664910e2f735178b9e1824a3ca3b115ff52837 Mon Sep 17 00:00:00 2001 From: Ray Date: Thu, 30 Jul 2026 20:25:53 +0800 Subject: [PATCH 05/23] Sync PageIndex Flash from private branch --- pageindex/flash/api.py | 34 +++++++++++++++++++++++++++++++--- pageindex/flash/main.py | 8 ++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/pageindex/flash/api.py b/pageindex/flash/api.py index 1620c9814..d5a819ea8 100644 --- a/pageindex/flash/api.py +++ b/pageindex/flash/api.py @@ -68,9 +68,37 @@ def _validate_pdf(pdf): return pdf -def page_index_flash(pdf) -> dict: - """Build a PageIndex tree structure from a PDF using layout statistics, without an LLM. Args: pdf: path to a PDF file (``str`` or ``pathlib.Path``) or an in-memory binary stream (``io.BytesIO``). Returns: dict with keys ``doc_name``, ``doc_title``, ``structure`` (a list of nested ``{"title", "start_index", "end_index", "nodes"}`` dicts; page indexes are 1-based) and ``has_abstract_or_references_section`` (True when a top-level entry is an abstract or references heading). """ - return extract_toc(_validate_pdf(pdf)) +def _thin(structure): + from ..utils import page_level_thinning, write_node_id + page_level_thinning(structure) + write_node_id(structure) + + +async def _summarize(structure, page_list, model): + from ..utils import add_node_text, generate_summaries_for_structure, remove_structure_text + add_node_text(structure, page_list) + await generate_summaries_for_structure(structure, model=model) + remove_structure_text(structure) + + +def page_index_flash(pdf, summary=True, summary_model=None) -> dict: + """Build a PageIndex tree structure from a PDF using layout statistics, without an LLM. Args: pdf: path to a PDF file (``str`` or ``pathlib.Path``) or an in-memory binary stream (``io.BytesIO``). summary: if True, generate LLM summaries for each node (requires ``summary_model``). summary_model: the LLM model identifier to use for summary generation. Returns: dict with keys ``doc_name``, ``doc_title``, ``structure`` (a list of nested ``{"title", "start_index", "end_index", "nodes"}`` dicts; page indexes are 1-based) and ``has_abstract_or_references_section`` (True when a top-level entry is an abstract or references heading). """ + result = extract_toc(_validate_pdf(pdf)) + structure = result.get("structure", []) + if structure: + _thin(structure) + if summary and structure: + import asyncio + from ..utils import ConfigLoader + if summary_model is None: + cfg = ConfigLoader().load() + summary_model = getattr(cfg, 'summary_model', None) or cfg.model + page_texts = result.pop("page_texts", []) + page_list = [(text, 0) for text in page_texts] + asyncio.run(_summarize(structure, page_list, summary_model)) + else: + result.pop("page_texts", None) + return result __all__ = ["page_index_flash"] diff --git a/pageindex/flash/main.py b/pageindex/flash/main.py index 69cefd24c..cd0a24a08 100644 --- a/pageindex/flash/main.py +++ b/pageindex/flash/main.py @@ -295,11 +295,19 @@ def extract_toc( else: doc_name = "document.pdf" + page_texts = [] + for page in pages: + parts = [] + for block in (page.secondary_slot or []): + parts.append(block_text(block)) + page_texts.append("\n".join(parts)) + return { "doc_name": doc_name, "doc_title": doc_title, "structure": structure, "has_abstract_or_references_section": has_abstract_or_references, + "page_texts": page_texts, } From 9c7447143921bdc81bab250ebf385428a285ca62 Mon Sep 17 00:00:00 2001 From: Ray Date: Fri, 31 Jul 2026 17:31:31 +0800 Subject: [PATCH 06/23] Add tree optimization (merge & expand) --- README.md | 19 + pageindex/__init__.py | 1 + pageindex/tree_optimize.py | 854 +++++++++++++++++++++++++++++++++++++ run_pageindex.py | 25 +- 4 files changed, 898 insertions(+), 1 deletion(-) create mode 100644 pageindex/tree_optimize.py diff --git a/README.md b/README.md index e08515649..72e83e489 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,25 @@ python3 run_pageindex.py --md_path /path/to/your/document.md > ```bash > python3 run_pageindex.py --flash --pdf_path /path/to/your/document.pdf > ``` +> +> In Flash output, a node's `end_index` covers its whole section, subsections included; the pages between a parent's heading and its first child run from `start_index` to the first child's `start_index`. + +### Tree Optimization *(preview)* + +`--optimize` refines a Flash tree to minimize **worst-case search cost**: how many pages a search must read in the worst case, counting one page per routing step. Two operators run until the tree stops changing: + +- **merge** — collapses any subtree whose structure costs more to route through than its pages cost to read. Deterministic, no LLM. The removed titles are kept on the parent as `key_items`. +- **expand** — splits a large section into the subsections actually printed on its pages, when routing into them is cheaper than scanning the section. Uses the summary model for one lookahead call per large section. + +```bash +# merge + expand, then summaries +python3 run_pageindex.py --flash --optimize --pdf_path /path/to/your/document.pdf + +# merge only: deterministic, no extra LLM calls +python3 run_pageindex.py --flash --optimize-merge-only --pdf_path /path/to/your/document.pdf +``` + +The output gains an `optimize` key reporting merge/expand counts and before/after search-cost metrics. Node summaries are generated after optimization, so they always describe the final tree. ## 🚀 Agentic Vectorless RAG: An Example diff --git a/pageindex/__init__.py b/pageindex/__init__.py index 658003bf5..3f4df8d6d 100644 --- a/pageindex/__init__.py +++ b/pageindex/__init__.py @@ -2,3 +2,4 @@ from .page_index_md import md_to_tree from .retrieve import get_document, get_document_structure, get_page_content from .client import PageIndexClient +from .tree_optimize import optimize_tree diff --git a/pageindex/tree_optimize.py b/pageindex/tree_optimize.py new file mode 100644 index 000000000..9e8dd8b1e --- /dev/null +++ b/pageindex/tree_optimize.py @@ -0,0 +1,854 @@ +"""Tree optimization: merge and expand driven by worst-case search cost. + +Refines a PageIndex tree so that navigating it is never more expensive than +necessary. Search cost is measured in pages, routing cost R(v) = 1 page: + + S(v) pages to linearly scan if v is collapsed = the whole subtree span + R(v) cost of visiting v for routing (title, summary, child descriptions) + S_residual(v) source pages of v covered by no child + +expand() - for a collapsed node, one-step lookahead, children treated as collapsed: + + trigger: S(v) > TRIGGER_PAGES (cost control on generation, not the rule) + collapse_cost = S(v) + expand_cost = R(v) + max(S_residual(v), max_i S(c_i)) + expand iff expand_cost < collapse_cost (ties keep collapsed) + expand_gain = collapse_cost - expand_cost + +merge() - for a node that already has a subtree, decided bottom-up: + + merge_cost = S(v) + tree_cost(v) = S(v) if v is a frontier node + = R(v) + max(S_residual(v), max_c tree_cost(c)) if v is expanded + merge iff merge_cost <= tree_cost(v) (ties merge) + merge_gain = tree_cost(v) - merge_cost + +tree_cost has an equivalent frontier form, computed independently here and +cross-checked against the recursion on every merge decision: + + tree_cost(v) = max over frontier u [ d(v, u) + S(u) ] + +A node with residual pages contributes a virtual frontier entry one hop below +itself. The maximum is over every branch - the deepest leaf need not be the most +expensive one. + +When a subtree is merged away, the removed titles are kept on the parent as +`key_items`: the pages stay reachable by scanning the parent, but the titles +are routing information that would otherwise be lost. + +merge is deterministic and needs no LLM; expand proposes subsections with the +model configured as `summary_model` (falling back to `model`) in config.yaml. + +Usage: + python3 -m pageindex.tree_optimize --pdf doc.pdf --structure tree.json --plan + python3 -m pageindex.tree_optimize --pdf doc.pdf --structure tree.json --no-expand + python3 -m pageindex.tree_optimize --pdf doc.pdf --structure tree.json --out out.json +""" + +import argparse +import asyncio +import copy +import json +import os +import re +import sys +from types import SimpleNamespace + +from .utils import ConfigLoader, _is_openai_model, llm_acompletion + +TRIGGER_PAGES = 5 # only look ahead on nodes larger than this +ROUTING_COST = 1 # R(v), in pages +PAGE_CHARS = 6000 # per-page text handed to the model + +EXPAND_PROMPT = """You are splitting an over-long section of a PDF into its subsections. + +Section title: {title} +Pages: {start}-{end} + +{pages} + +List the subsection headings that BEGIN within these pages, in document order, +each with the page number it begins on. Rules: + +- Use only headings printed in the document. Never invent or paraphrase one. +- A running header, a table column label, a table row label, or a cross-reference + is not a subsection heading. +- If this section is continuous prose, or a single table spanning the pages, + return an empty list. That is a valid and expected answer. +- Do not include the section's own title. + +Reply with JSON only: +{{"subsections": [{{"title": "", "page": }}]}}""" + + +# -------------------------------------------------------------------------- +# basics +# -------------------------------------------------------------------------- + +def note(enabled, message): + """Progress line on stderr; stdout keeps only the metrics and the summary.""" + if enabled: + print(message, file=sys.stderr, flush=True) + +def normalize(text): + return re.sub(r"[^a-z0-9]+", " ", (text or "").lower()).strip() + + +def flatten(nodes, parent=None): + """Depth-first walk yielding (node, parent) for every node in the tree.""" + for node in nodes: + yield node, parent + yield from flatten(node.get("nodes") or [], node) + + +def extract_json(content): + """Pull a JSON object out of a model reply, fenced or not.""" + if not content: + # providers can return content=None (empty completion, filtered reply) + raise ValueError("model returned no content") + text = content.strip() + if "```" in text: + text = re.sub(r"^.*?```(?:json)?\s*", "", text, flags=re.S) + text = text.split("```")[0] + start, end = text.find("{"), text.rfind("}") + if start == -1 or end == -1: + raise ValueError(f"no JSON object in reply: {content[:200]!r}") + return json.loads(text[start:end + 1]) + + +async def ask_model(model, prompt): + return extract_json(await llm_acompletion(model, prompt)) + + +def load_pages(pdf_path): + """Per-page text, and per-page lines ordered top to bottom.""" + import pymupdf + doc = pymupdf.open(pdf_path) + text, lines = [], [] + for page in doc: + text.append(page.get_text()) + ordered = [] + for block in page.get_text("dict")["blocks"]: + for line in block.get("lines", []): + content = "".join(s["text"] for s in line["spans"]).strip() + if content: + ordered.append((line["bbox"][1], content)) + ordered.sort() + lines.append([c for _, c in ordered]) + return text, lines + + +# -------------------------------------------------------------------------- +# tree geometry +# -------------------------------------------------------------------------- + +def subtree_end(node): + """Last page covered by this node or any descendant. + + A node's own end_index already spans its whole subtree (union semantics); + the walk keeps legacy trees working, where a parent's end_index stopped at + its first child. + """ + end = node["end_index"] + for child, _ in flatten(node.get("nodes") or []): + end = max(end, child["end_index"]) + return end + + +def is_frontier(node): + return not node.get("nodes") + + +def heading_at_page_start(lines, page_no, heading): + """Is the heading the first line on its page?""" + page = lines[page_no - 1] + if not page: + return False + return normalize(heading) in normalize(page[0]) + + +def assign_ends(node, children, lines): + """end_index for a candidate level, without committing it to the node. + + end = next.start - 1 when the next heading opens its page, else next.start. + """ + sized = [dict(c) for c in children] + old_end = subtree_end(node) + for index, child in enumerate(sized): + if index + 1 < len(sized): + nxt = sized[index + 1] + if heading_at_page_start(lines, nxt["start_index"], nxt["title"]): + child["end_index"] = max(child["start_index"], nxt["start_index"] - 1) + else: + child["end_index"] = nxt["start_index"] + else: + child["end_index"] = old_end + return sized + + +def attach_children(node, children, lines): + # union semantics: the parent's end_index already covers the subtree span, + # so gaining children leaves it unchanged + sized = assign_ends(node, children, lines) + node["nodes"] = sized + return sized + + +def relabel(structure, width=4): + """Renumber every node_id in document order: 0000, 0001, 0002, ... + + Expansion mints ids like "0266.1" to show provenance; once the tree is final + those are replaced by a flat sequence. flatten() is pre-order depth-first, + which is document order for a well-formed tree. + + Returns the old -> new mapping so a log written against the old ids can still + be followed. + """ + mapping = {} + for counter, (node, _) in enumerate(flatten(structure)): + old = node.get("node_id") + new = f"{counter:0{width}d}" + if old is not None: + mapping[old] = new + node["node_id"] = new + return mapping + + +# -------------------------------------------------------------------------- +# cost model +# -------------------------------------------------------------------------- + +def pages_of(node): + return set(range(node["start_index"], subtree_end(node) + 1)) + + +def S(node): + """Pages to scan linearly if this node were collapsed.""" + return subtree_end(node) - node["start_index"] + 1 + + +def S_residual(node): + """Pages of the node covered by no child.""" + children = node.get("nodes") or [] + if not children: + return S(node) + covered = set() + for child in children: + covered |= pages_of(child) + return len(pages_of(node) - covered) + + +def tree_cost(node, routing=ROUTING_COST): + """Worst-case search cost of the subtree as it currently stands.""" + if is_frontier(node): + return S(node) + branches = [tree_cost(c, routing) for c in node["nodes"]] + residual = S_residual(node) + if residual: + branches.append(residual) + return routing + max(branches) + + +def frontier_costs(node, distance=0): + """Every branch as (routing distance, scan pages, label) - the frontier form. + + Distances are returned unweighted; the caller multiplies by R. + """ + if is_frontier(node): + return [(distance, S(node), node.get("node_id"))] + entries = [] + residual = S_residual(node) + if residual: + entries.append((distance + 1, residual, f"{node.get('node_id')}:residual")) + for child in node["nodes"]: + entries.extend(frontier_costs(child, distance + 1)) + return entries + + +def tree_cost_via_frontier(node, routing=ROUTING_COST): + entries = frontier_costs(node) + return max(d * routing + s for d, s, _ in entries) if entries else 0 + + +def expand_cost(node, children, routing=ROUTING_COST): + """Cost after one-step lookahead, children treated as collapsed.""" + covered = set() + for child in children: + covered |= set(range(child["start_index"], child["end_index"] + 1)) + residual = len(pages_of(node) - covered) + scans = [child["end_index"] - child["start_index"] + 1 for child in children] + return routing + max([residual] + scans), residual + + +# -------------------------------------------------------------------------- +# search-complexity metrics over a whole tree +# -------------------------------------------------------------------------- + +def frontier_nodes(structure, root_depth=1): + """(node, depth) for every frontier node of the tree. + + depth counts routing visits on the way in. root_depth=1 charges one visit for + routing at the document level, so a top-level frontier node costs + 1 + pages(u) - the same convention as tree_cost() applied from the document. + """ + found = [] + + def visit(node, depth): + if is_frontier(node): + found.append((node, depth)) + return + for child in node["nodes"]: + visit(child, depth + 1) + if S_residual(node): + # pages held by the node itself are reached by routing into it, then + # scanning what no child covers + found.append((node, depth + 1)) + + for root in structure: + visit(root, root_depth) + return found + + +def pages(node): + """Pages that must be scanned once search arrives at this frontier node.""" + return S_residual(node) if not is_frontier(node) else S(node) + + +def worst_case_search_complexity(structure, root_depth=1, routing=ROUTING_COST): + """max over frontier u [ R * depth(u) + pages(u) ]""" + entries = frontier_nodes(structure, root_depth) + if not entries: + return 0 + return max(depth * routing + pages(node) for node, depth in entries) + + +def average_search_complexity(structure, total_pages, root_depth=1, routing=ROUTING_COST): + """sum over frontier u [ p(u) * (R * depth(u) + (pages(u)+1)/2) ] + + p(u) = pages(u) / total_pages, the chance the target lies in u; the expected + position of a uniformly placed target inside a linear scan of n pages is + (n+1)/2. + """ + if not total_pages: + return 0.0, 0.0 + total = 0.0 + weight = 0.0 + for node, depth in frontier_nodes(structure, root_depth): + n = pages(node) + p = n / total_pages + weight += p + total += p * (depth * routing + (n + 1) / 2) + return total, weight + + +def normalized_worst_case_complexity(structure, total_pages, root_depth=1, + routing=ROUTING_COST): + """worst_case_search_complexity(T) / total_pages""" + if not total_pages: + return 0.0 + return worst_case_search_complexity(structure, root_depth, routing) / total_pages + + +METRIC_LABELS = [ + ("worst_case_search_complexity", "Worst-Case Search Complexity"), + ("average_search_complexity", "Average Search Complexity"), + ("normalized_worst_case_complexity", "Normalized Worst-Case Complexity"), +] + + +def print_metrics(heading, metrics): + print(heading) + for key, label in METRIC_LABELS: + print(f" {label:<34} {metrics[key]}") + + +def complexity(structure, total_pages, root_depth=1, routing=ROUTING_COST): + """The three search-complexity metrics for the whole tree.""" + entries = frontier_nodes(structure, root_depth) + worst = worst_case_search_complexity(structure, root_depth, routing) + average, weight = average_search_complexity(structure, total_pages, + root_depth, routing) + depths = [d for _, d in entries] + return { + "total_pages": total_pages, + "frontier_nodes": len(entries), + "worst_case_search_complexity": worst, + "average_search_complexity": round(average, 3), + "normalized_worst_case_complexity": round( + normalized_worst_case_complexity(structure, total_pages, root_depth, routing), 4), + "max_depth": max(depths) if depths else 0, + "mean_depth": round(sum(depths) / len(depths), 2) if depths else 0, + # 1.0 when frontier pages partition the document; above 1.0 means frontier + # ranges overlap (the end_index convention lets a section share a page) + "probability_mass": round(weight, 4), + } + + +# -------------------------------------------------------------------------- +# validation +# -------------------------------------------------------------------------- + +def structural_issues(node, parent, page_count): + issues = [] + start, end = node.get("start_index"), node.get("end_index") + + for name, value in (("start_index", start), ("end_index", end)): + if not isinstance(value, int): + issues.append(f"{name} is {value!r}, expected an integer") + if not isinstance(start, int) or not isinstance(end, int): + return issues + + if start < 1 or start > page_count: + issues.append(f"start_index {start} outside the PDF (1-{page_count})") + if end < 1 or end > page_count: + issues.append(f"end_index {end} outside the PDF (1-{page_count})") + if start > end: + issues.append(f"start_index {start} is after end_index {end}") + + # legacy trees let children extend past the parent's end_index, so only the + # start is checked against the parent + if parent and isinstance(parent.get("start_index"), int): + if start < parent["start_index"]: + issues.append(f"start_index {start} precedes parent " + f"{parent.get('node_id')} (starts {parent['start_index']})") + return issues + + +def _sibling_groups(nodes, parent_id=None): + yield parent_id, nodes + for node in nodes: + if node.get("nodes"): + yield from _sibling_groups(node["nodes"], node.get("node_id")) + + +def ordering_issues(nodes): + """Siblings must be listed in the order they appear in the document.""" + found = {} + for _, group in _sibling_groups(nodes): + previous = None + for node in group: + start = node.get("start_index") + if isinstance(start, int) and isinstance(previous, int) and start < previous: + found.setdefault(node.get("node_id"), []).append( + f"start_index {start} is before the preceding sibling's {previous}") + if isinstance(start, int): + previous = start + return found + + +def validate(structure, page_count): + issues = [] + for node, parent in flatten(structure): + for problem in structural_issues(node, parent, page_count): + issues.append(f"[{node.get('node_id')}] {problem}") + for node_id, problems in ordering_issues(structure).items(): + issues.extend(f"[{node_id}] {p}" for p in problems) + covered = set() + for node, _ in flatten(structure): + covered |= set(range(node["start_index"], node["end_index"] + 1)) + gaps = [p for p in range(1, page_count + 1) if p not in covered] + if gaps: + issues.append(f"pages covered by no node: {gaps}") + return issues + + +# -------------------------------------------------------------------------- +# MERGE +# -------------------------------------------------------------------------- + +def merge(structure, routing, log, frozen, progress=False): + """Collapse any subtree whose structure does not beat a linear scan. + + Bottom-up: merging a deep subtree changes its ancestors' tree_cost, so the + deepest decisions have to be made first. + """ + changed = False + + def visit(node): + nonlocal changed + if is_frontier(node): + return + for child in list(node.get("nodes") or []): + visit(child) + if is_frontier(node): # every child collapsed away + return + + cost = tree_cost(node, routing) + checked = tree_cost_via_frontier(node, routing) + span = S(node) + if span <= cost: + removed = [c["node_id"] for c, _ in flatten(node["nodes"])] + # titles are routing information; keep them on the parent, in document + # order, carrying forward anything an earlier merge already folded in + titles = [] + for child, _ in flatten(node["nodes"]): + titles.append(child["title"]) + titles.extend(child.get("key_items") or []) + log.append({"op": "merge", "node_id": node.get("node_id"), + "S": span, "tree_cost": cost, "frontier_cost": checked, + "merge_gain": cost - span, "removed": len(removed), + "removed_ids": removed, "key_items": titles, + "frontier": sorted(frontier_costs(node, routing), + key=lambda e: -(e[0] * routing + e[1]))[:5]}) + node["end_index"] = subtree_end(node) + node.pop("nodes", None) + if titles: + node["key_items"] = titles + frozen.add(node.get("node_id")) + changed = True + note(progress, f" merge {node.get('node_id'):>8} " + f"S={span} <= tree_cost={cost} dropped {len(removed)} node(s)") + + for root in list(structure): + visit(root) + return changed + + +# -------------------------------------------------------------------------- +# EXPAND +# -------------------------------------------------------------------------- + +def load_headings_cache(path): + """page -> [heading, ...] from a per-page detection pass, or None.""" + if not path or not os.path.exists(path): + return None + data = json.load(open(path)) + index = {} + for record in data.get("pages") or []: + if record.get("headings"): + index[record["page"]] = record["headings"] + return index + + +def children_from_cache(node, cache, kinds): + """Candidate level taken from a cached per-page detection - no API call.""" + if not cache: + return [] + start, end = node["start_index"], subtree_end(node) + out = [] + for page in range(start, end + 1): + for heading in cache.get(page) or []: + if kinds and heading.get("kind") not in kinds: + continue + if normalize(heading["title"]) == normalize(node["title"]): + continue + out.append({"title": heading["title"], "start_index": page, "end_index": end, + "node_id": f"{node['node_id']}.{len(out) + 1}"}) + return out + + +async def propose_children(node, pages, args): + """Generate one temporary level of children via the model. Validated, not committed.""" + start, end = node["start_index"], subtree_end(node) + block = "\n".join( + f"\n{pages[n - 1][:PAGE_CHARS]}\n" for n in range(start, end + 1)) + answer = await ask_model(args.model, EXPAND_PROMPT.format( + title=node["title"], start=start, end=end, pages=block)) + + accepted, seen = [], set() + for item in answer.get("subsections") or []: + title, page = (item or {}).get("title"), (item or {}).get("page") + if not isinstance(page, int) or not start <= page <= end or not title: + continue + if normalize(title) not in normalize(pages[page - 1]): + continue # the heading must be printed on that page + if normalize(title) in seen or normalize(title) == normalize(node["title"]): + continue + if accepted and page < accepted[-1]["start_index"]: + continue + seen.add(normalize(title)) + accepted.append({"title": title.strip(), "start_index": page, "end_index": end, + "node_id": f"{node['node_id']}.{len(accepted) + 1}"}) + return accepted + + +async def expand(structure, pages, lines, args, log, frozen): + """One-step lookahead on every collapsed node over the trigger, recursively. + + Candidate levels come from every available source - a cached per-page + detection and the model itself. Neither is reliably better (detection wins on + prose, a whole-node prompt wins on dense tables), so all candidates are + priced with expand_cost and the cheapest is kept. + """ + changed = False + queue = [n for n, _ in flatten(structure)] + + while queue: + node = queue.pop(0) + if not is_frontier(node) or node.get("node_id") in frozen: + continue + + span = S(node) + if span <= args.trigger_pages: + continue # below the trigger, stay collapsed + + note(args.progress, f" expand {node.get('node_id'):>8} S={span} " + f"pages {node['start_index']}-{subtree_end(node)} ...") + candidates = [] + cached = children_from_cache(node, args.cache, args.kinds) + if cached: + candidates.append(("cache", cached)) + + attempts = 0 + while attempts <= args.empty_retries: + attempts += 1 + try: + proposed = await propose_children(node, pages, args) + except Exception as exc: + log.append({"op": "expand", "node_id": node.get("node_id"), + "decision": "error", "attempt": attempts, + "detail": f"{type(exc).__name__}: {exc}"}) + continue + if proposed: + candidates.append((f"llm:{attempts}", proposed)) + break # an empty answer is retried, not trusted + + if not candidates: + note(args.progress, f" -> no children found, kept collapsed") + log.append({"op": "expand", "node_id": node.get("node_id"), + "decision": "no_children", "S": span, "attempts": attempts}) + frozen.add(node.get("node_id")) + continue + + scored = [] + for source, children in candidates: + sized = assign_ends(node, children, lines) + cost, residual = expand_cost(node, sized, args.routing) + scored.append({"source": source, "children": sized, + "expand_cost": cost, "S_residual": residual}) + scored.sort(key=lambda s: s["expand_cost"]) + best = scored[0] + + cost = best["expand_cost"] + gain = span - cost + ratio = gain / span if span else 0.0 + keep = cost < span and ratio >= args.min_gain_ratio + + note(args.progress, + f" -> {len(best['children'])} children from {best['source']}, " + f"cost {cost} vs {span}, " + f"{'expand (gain %d)' % gain if keep else 'kept collapsed'}") + log.append({"op": "expand", "node_id": node.get("node_id"), + "decision": "expand" if keep else "keep_collapsed", + "S": span, "expand_cost": cost, "expand_gain": gain, + "gain_ratio": round(ratio, 3), "S_residual": best["S_residual"], + "source": best["source"], + "considered": [{"source": s["source"], "children": len(s["children"]), + "expand_cost": s["expand_cost"]} for s in scored], + "children": [{"node_id": c["node_id"], "title": c["title"], + "start_index": c["start_index"], + "end_index": c["end_index"], + "S": c["end_index"] - c["start_index"] + 1} + for c in best["children"]]}) + + frozen.add(node.get("node_id")) + if keep: + changed = True + attach_children(node, best["children"], lines) + queue.extend(node["nodes"]) # recurse into the kept children + return changed + + +# -------------------------------------------------------------------------- +# driver +# -------------------------------------------------------------------------- + +def default_model(): + """Expand follows the summary model: both are cheap text-extraction calls.""" + opt = ConfigLoader().load({}) + return getattr(opt, "summary_model", None) or opt.model + + +async def optimize(structure, pages, lines, model=None, routing=ROUTING_COST, + trigger_pages=TRIGGER_PAGES, min_gain_ratio=0.0, + do_merge=True, do_expand=True, max_rounds=3, page_count=None, + cache=None, kinds=("section", "table"), empty_retries=1, + do_relabel=True, progress=False): + """Run merge and expand over a tree until neither changes anything. + + Mutates `structure` in place and returns a summary. + + A round is merge then expand, repeated because children created by expand + have not been merge-checked yet, and a subtree collapsed by merge changes its + ancestors' tree_cost. Nodes decided by either operator are frozen for the rest + of the run, so a node cannot be collapsed and re-expanded in alternating + rounds. + """ + if do_expand and pages is None: + raise ValueError("expand needs the PDF pages; pass pages/lines or do_expand=False") + opts = SimpleNamespace(model=model or default_model(), routing=routing, + trigger_pages=trigger_pages, + min_gain_ratio=min_gain_ratio, cache=cache, + kinds=set(kinds) if kinds else None, + empty_retries=empty_retries, progress=progress) + baseline = set(validate(structure, page_count)) if page_count else set() + before = complexity(structure, page_count, routing=routing) if page_count else {} + + log, frozen = [], set() + rounds = 0 + for round_no in range(1, max_rounds + 1): + rounds = round_no + note(progress, f" round {round_no}") + merged = merge(structure, routing, log, frozen, progress) if do_merge else False + expanded = await expand(structure, pages, lines, opts, log, frozen) \ + if do_expand else False + log.append({"op": "round", "round": round_no, "merged": merged, "expanded": expanded}) + if not (merged or expanded): + break + + id_map = relabel(structure) if do_relabel else {} + after = complexity(structure, page_count, routing=routing) if page_count else {} + issues = [i for i in validate(structure, page_count) if i not in baseline] \ + if page_count else [] + return {"structure": structure, "log": log, "rounds": rounds, + "before": before, "after": after, "id_map": id_map, + "merges": sum(1 for e in log if e["op"] == "merge"), + "expands": sum(1 for e in log if e.get("decision") == "expand"), + "kept_collapsed": sum(1 for e in log if e.get("decision") == "keep_collapsed"), + "new_issues": issues} + + +def optimize_tree(doc, pdf_path=None, model=None, do_expand=None, **kwargs): + """Synchronous entry point over a loaded structure dict or a JSON path. + + `doc` is the {"structure": [...]} dict produced by the tree builders (other + keys are preserved). Without `pdf_path` only merge runs; expand needs the + page text. Returns the run summary; the refined tree is doc["structure"]. + """ + if isinstance(doc, str): + doc = json.load(open(doc)) + structure = doc["structure"] + pages = lines = None + page_count = kwargs.pop("page_count", None) + if pdf_path: + pages, lines = load_pages(pdf_path) + page_count = len(pages) + if do_expand is None: + do_expand = pdf_path is not None + result = asyncio.run(optimize(structure, pages, lines, model=model, + page_count=page_count, do_expand=do_expand, + **kwargs)) + doc["structure"] = result["structure"] + return result + + +def report_costs(structure, routing, trigger): + rows = [] + for node, _ in flatten(structure): + rows.append({"node_id": node.get("node_id"), "title": node.get("title"), + "S": S(node), "frontier": is_frontier(node), + "tree_cost": tree_cost(node, routing), + "S_residual": S_residual(node), + "children": len(node.get("nodes") or [])}) + merges = [r for r in rows if not r["frontier"] and r["S"] <= r["tree_cost"]] + triggers = [r for r in rows if r["frontier"] and r["S"] > trigger] + return rows, merges, triggers + + +async def main(): + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--pdf", required=True, help="source document") + parser.add_argument("--structure", required=True, help="input tree JSON") + parser.add_argument("--model", default=None, + help="model for expand (default: summary_model from config.yaml)") + parser.add_argument("--trigger-pages", type=int, default=TRIGGER_PAGES, + help=f"only look ahead above this page count (default {TRIGGER_PAGES})") + parser.add_argument("--routing", type=int, default=ROUTING_COST, + help="R(v), cost of visiting a node, in pages (default 1)") + parser.add_argument("--min-gain-ratio", type=float, default=0.0, + help="require expand_gain / S(v) to reach this (e.g. 0.10)") + parser.add_argument("--headings", default=None, + help="per-page detection cache used as an extra candidate source") + parser.add_argument("--kinds", default="section,table", + help="heading kinds accepted from the cache (default section,table)") + parser.add_argument("--empty-retries", type=int, default=1, + help="extra lookahead attempts when the model returns no children") + parser.add_argument("--no-relabel", dest="relabel", action="store_false", + help="keep provenance ids like 0266.1 instead of renumbering") + parser.add_argument("--no-merge", dest="merge", action="store_false") + parser.add_argument("--no-expand", dest="expand", action="store_false") + parser.add_argument("--rounds", type=int, default=3) + parser.add_argument("--plan", action="store_true", help="costs and decisions, no API calls") + parser.add_argument("--out", default=None, + help="output tree (default: .optimized.json)") + parser.add_argument("--log", help="write the per-decision log here (off by default)") + parser.add_argument("--quiet", "-q", action="store_true", + help="no progress lines on stderr") + parser.add_argument("--verbose", "-v", action="store_true", + help="also list the merge and expand candidates before running") + args = parser.parse_args() + + model = args.model or default_model() + if args.expand and not args.plan and _is_openai_model(model) \ + and not os.getenv("OPENAI_API_KEY"): + sys.exit(f"OPENAI_API_KEY is not set (expand model: {model}).") + + original = json.load(open(args.structure)) + structure = copy.deepcopy(original["structure"]) + pages, lines = load_pages(args.pdf) + page_count = len(pages) + out_path = args.out or re.sub(r"(\.json)?$", ".optimized.json", + args.structure, count=1) + + rows, merges, triggers = report_costs(structure, args.routing, args.trigger_pages) + metrics = complexity(structure, page_count, routing=args.routing) + print(f"{len(rows)} nodes | R={args.routing} | trigger>{args.trigger_pages} pages | " + f"{page_count} pages") + if args.plan: + print() + print_metrics("Metrics", metrics) + if args.verbose or args.plan: + print(f"\nmerge candidates, S(v) <= tree_cost(v): {len(merges)}") + for r in sorted(merges, key=lambda r: -(r["tree_cost"] - r["S"]))[:10]: + print(f" {r['node_id']:>8} S={r['S']:>3} tree_cost={r['tree_cost']:>3} " + f"gain={r['tree_cost'] - r['S']:>3} kids={r['children']:<2} {r['title'][:40]}") + print(f"\nexpand candidates, collapsed and over the trigger: {len(triggers)}") + for r in sorted(triggers, key=lambda r: -r["S"])[:10]: + print(f" {r['node_id']:>8} S={r['S']:>3} {r['title'][:52]}") + + if args.plan: + pre = validate(structure, page_count) + print(f"\nvalidation on input: {len(pre)} issue(s)") + for issue in pre[:10]: + print(f" {issue}") + return 0 + + result = await optimize(structure, pages, lines, model=model, routing=args.routing, + trigger_pages=args.trigger_pages, + min_gain_ratio=args.min_gain_ratio, + do_merge=args.merge, do_expand=args.expand, + max_rounds=args.rounds, page_count=page_count, + cache=load_headings_cache(args.headings), + kinds=[k.strip() for k in args.kinds.split(",") if k.strip()], + empty_retries=args.empty_retries, + do_relabel=args.relabel, progress=not args.quiet) + + print(f"\nrounds={result['rounds']} merges={result['merges']} " + f"expands={result['expands']} kept_collapsed={result['kept_collapsed']}") + print(f"nodes {len(list(flatten(original['structure'])))} -> " + f"{len(list(flatten(structure)))}") + print() + print_metrics("Before optimize", result["before"]) + print() + print_metrics("After optimize", result["after"]) + if result["new_issues"]: + print(f"\nnew validation issues: {result['new_issues']}") + + refined = dict(original) + refined["structure"] = structure + json.dump(refined, open(out_path, "w"), indent=2, ensure_ascii=False) + print(f"\nstructure: {out_path}") + + if args.log: + json.dump({"routing": args.routing, "trigger_pages": args.trigger_pages, + "min_gain_ratio": args.min_gain_ratio, "model": model, + "before": result["before"], "after": result["after"], + "id_map": result["id_map"], "events": result["log"]}, + open(args.log, "w"), indent=2, ensure_ascii=False) + print(f"log: {args.log}") + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/run_pageindex.py b/run_pageindex.py index 893cc3633..95ca782bc 100644 --- a/run_pageindex.py +++ b/run_pageindex.py @@ -11,6 +11,10 @@ parser.add_argument('--pdf_path', type=str, help='Path to the PDF file') parser.add_argument('--md_path', type=str, help='Path to the Markdown file') parser.add_argument('--flash', action='store_true', help='Use PageIndex Flash (with --pdf_path)') + parser.add_argument('--optimize', action='store_true', + help='Refine the tree for search cost: merge + LLM expand (PDF only)') + parser.add_argument('--optimize-merge-only', action='store_true', + help='Refine the tree with merge only, no LLM calls (PDF only)') parser.add_argument('--model', type=str, default=None, help='Model to use (overrides config.yaml)') @@ -44,6 +48,8 @@ raise ValueError("Either --pdf_path or --md_path must be specified") if args.pdf_path and args.md_path: raise ValueError("Only one of --pdf_path or --md_path can be specified") + if (args.optimize or args.optimize_merge_only) and not (args.pdf_path and args.flash): + raise ValueError("--optimize / --optimize-merge-only require --flash with --pdf_path") if args.pdf_path: # Validate PDF file @@ -54,7 +60,24 @@ if args.flash: from pageindex.flash import page_index_flash - toc_with_page_number = page_index_flash(args.pdf_path) + if args.optimize: + from pageindex.tree_optimize import default_model + from pageindex.utils import _is_openai_model + expand_model = args.model or default_model() + if _is_openai_model(expand_model) and not os.getenv("OPENAI_API_KEY"): + raise SystemExit(f"OPENAI_API_KEY is not set (expand model: {expand_model}).") + toc_with_page_number = page_index_flash( + args.pdf_path, + optimize=args.optimize or args.optimize_merge_only, + optimize_expand=args.optimize, + optimize_model=args.model, + ) + if 'optimize' in toc_with_page_number: + o = toc_with_page_number['optimize'] + print(f"Optimize: merges={o['merges']} expands={o['expands']}, " + f"worst-case search cost " + f"{o['before'].get('worst_case_search_complexity')} -> " + f"{o['after'].get('worst_case_search_complexity')} pages") else: # Process PDF file user_opt = { From 205e4e761c28f5d2f431af8f306e23f99c063cfa Mon Sep 17 00:00:00 2001 From: Ray Date: Fri, 31 Jul 2026 17:32:00 +0800 Subject: [PATCH 07/23] Sync PageIndex Flash from private branch --- pageindex/flash/api.py | 31 ++++++++++++++++++-- pageindex/flash/outline/tree.py | 13 ++++++++ pageindex/flash/outline_assembly/assembly.py | 13 ++++++++ 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/pageindex/flash/api.py b/pageindex/flash/api.py index d5a819ea8..e1d8cdd96 100644 --- a/pageindex/flash/api.py +++ b/pageindex/flash/api.py @@ -81,11 +81,36 @@ async def _summarize(structure, page_list, model): remove_structure_text(structure) -def page_index_flash(pdf, summary=True, summary_model=None) -> dict: - """Build a PageIndex tree structure from a PDF using layout statistics, without an LLM. Args: pdf: path to a PDF file (``str`` or ``pathlib.Path``) or an in-memory binary stream (``io.BytesIO``). summary: if True, generate LLM summaries for each node (requires ``summary_model``). summary_model: the LLM model identifier to use for summary generation. Returns: dict with keys ``doc_name``, ``doc_title``, ``structure`` (a list of nested ``{"title", "start_index", "end_index", "nodes"}`` dicts; page indexes are 1-based) and ``has_abstract_or_references_section`` (True when a top-level entry is an abstract or references heading). """ +def _optimize(structure, page_texts, do_expand, model): + """Merge/expand refinement between extraction and summaries. + + Supersedes ``_thin``: merge collapses everything thinning would, but keeps + the dropped titles as ``key_items``. Summaries run after, so they describe + the final tree. Expand reads the same page text the summaries use. + """ + import asyncio + from ..tree_optimize import optimize + lines = [[line_text.strip() for line_text in (page_text or "").splitlines() + if line_text.strip()] + for page_text in page_texts] + outcome = asyncio.run(optimize(structure, page_texts, lines, model=model, + do_expand=do_expand, + page_count=len(page_texts))) + return {"merges": outcome["merges"], "expands": outcome["expands"], + "before": outcome["before"], "after": outcome["after"]} + + +def page_index_flash(pdf, summary=True, summary_model=None, + optimize=False, optimize_expand=True, + optimize_model=None) -> dict: + """Build a PageIndex tree structure from a PDF using layout statistics, without an LLM. Args: pdf: path to a PDF file (``str`` or ``pathlib.Path``) or an in-memory binary stream (``io.BytesIO``). summary: if True, generate LLM summaries for each node (requires ``summary_model``). summary_model: the LLM model identifier to use for summary generation. optimize: if True, refine the tree for search cost (merge + expand) before summaries. optimize_expand: if False, optimization only merges - deterministic, no LLM calls. optimize_model: the LLM model for expand (defaults to the summary model). Returns: dict with keys ``doc_name``, ``doc_title``, ``structure`` (a list of nested ``{"title", "start_index", "end_index", "nodes"}`` dicts; page indexes are 1-based) and ``has_abstract_or_references_section`` (True when a top-level entry is an abstract or references heading). With ``optimize`` an ``optimize`` key reports merge/expand counts and before/after search-cost metrics. """ result = extract_toc(_validate_pdf(pdf)) structure = result.get("structure", []) - if structure: + if optimize and structure: + result["optimize"] = _optimize(structure, result.get("page_texts") or [], + optimize_expand, + optimize_model or summary_model) + elif structure: _thin(structure) if summary and structure: import asyncio diff --git a/pageindex/flash/outline/tree.py b/pageindex/flash/outline/tree.py index 2c41136af..c9469f46c 100644 --- a/pageindex/flash/outline/tree.py +++ b/pageindex/flash/outline/tree.py @@ -92,6 +92,19 @@ def _walk_nodes(nodes: list[dict]) -> None: if flat: flat[-1]["end_index"] = max(flat[-1]["start_index"], total_pages) + # Promote parent end_index to the subtree maximum: end_index covers the + # whole section, children included. The leading segment stays derivable + # from the first child's start_index. + def _promote(nodes: list[dict]) -> int: + end = 0 + for child in nodes: + if child["nodes"]: + child["end_index"] = max(child["end_index"], _promote(child["nodes"])) + end = max(end, child["end_index"]) + return end + + _promote(root) + # Drop empty children so the JSON matches the shape the rest of PageIndex emits. def _drop_empty_children(nodes: list[dict]) -> list[dict]: for count_item in nodes: diff --git a/pageindex/flash/outline_assembly/assembly.py b/pageindex/flash/outline_assembly/assembly.py index f0dee4b12..ea0ff4027 100644 --- a/pageindex/flash/outline_assembly/assembly.py +++ b/pageindex/flash/outline_assembly/assembly.py @@ -327,6 +327,19 @@ def _collect(nodes: list[dict]) -> None: if flat: flat[-1]["end_index"] = max(flat[-1]["start_index"], total_pages) + # Promote parent end_index to the subtree maximum: end_index covers the + # whole section, children included. The leading segment stays derivable + # from the first child's start_index. + def _promote(nodes: list[dict]) -> int: + end = 0 + for child in nodes: + if child["nodes"]: + child["end_index"] = max(child["end_index"], _promote(child["nodes"])) + end = max(end, child["end_index"]) + return end + + _promote(root) + # Stable DFS pre-order node ids, zero-padded to 4 (PageIndex convention; # uses zero-padded depth-first ids). Drop the # transient appear_start marker now that end_index is settled. From 910250c3e2203ce7554326ae57dcf1bd41cc3abd Mon Sep 17 00:00:00 2001 From: Ray Date: Fri, 31 Jul 2026 20:07:18 +0800 Subject: [PATCH 08/23] Add recursive node summaries for Flash --- pageindex/utils.py | 85 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 84 insertions(+), 1 deletion(-) diff --git a/pageindex/utils.py b/pageindex/utils.py index b1096ddfa..456bfac27 100644 --- a/pageindex/utils.py +++ b/pageindex/utils.py @@ -634,12 +634,95 @@ async def generate_summaries_for_structure(structure, model=None): nodes = structure_to_list(structure) tasks = [generate_node_summary(node, model=model) for node in nodes] summaries = await asyncio.gather(*tasks) - + for node, summary in zip(nodes, summaries): node['summary'] = summary return structure +def get_intro_text(node, pdf_pages, max_pages=3): + """Pages of the node covered by no child: from its start to just before the + first child starts. Empty when the first child opens on the node's own page.""" + children = node.get('nodes') or [] + first = children[0].get('start_index') if children else None + if not isinstance(first, int) or first <= node['start_index']: + return "" + end = min(first - 1, node['start_index'] + max_pages - 1) + return get_text_of_pdf_pages(pdf_pages, node['start_index'], end) + + +async def summarize_tree(structure, pdf_pages, model=None, small_node_tokens=200, + max_intro_pages=3, concurrency=32): + """Bottom-up summaries: leaves from their own pages, parents composed from + child summaries plus the pages no child covers. A parent's summary describes + its whole subtree (end_index union semantics). Nodes that already carry a + summary are left untouched; leaves under `small_node_tokens` use their raw + text as the summary without a model call.""" + semaphore = asyncio.Semaphore(concurrency) + + async def ask(prompt): + async with semaphore: + response = await llm_acompletion(model, prompt) + return (extract_json(response) or {}).get('summary') or "" + + async def leaf_summary(node): + text = get_text_of_pdf_pages(pdf_pages, node['start_index'], node['end_index']) + if count_tokens(text, model="gpt-4o") < small_node_tokens: + return text.strip() + prompt = f"""You are given a text chunk from a document. + Your task is to generate a concise description of everything that is covered in the text, summarizing all its points without omitting any type of content. + Keep the description concise and to the point, avoiding unnecessary details. + + Given Text: {text} + + Reply strictly in the following JSON format: + {{ + "points": , + "summary": + }} + + Follow strictly the above JSON return format. Do not include any other text! + """ + return await ask(prompt) + + async def parent_summary(node): + children = node['nodes'] + intro = get_intro_text(node, pdf_pages, max_pages=max_intro_pages) + listing = json.dumps( + [{'title': c.get('title', ''), 'summary': c.get('summary', '')} for c in children], + ensure_ascii=False) + prompt = f"""You are given a section of a document: the text that opens the section (possibly empty) and the titles and summaries of its subsections. + Your task is to generate a concise description of everything that is covered in the whole section, summarizing all its points without omitting any type of content. + Keep the description concise and to the point, avoiding unnecessary details. + + Section Title: {node.get('title', '')} + + Opening Text: {intro} + + Subsection Titles and Summaries: {listing} + + Reply strictly in the following JSON format: + {{ + "points": , + "summary": + }} + + Follow strictly the above JSON return format. Do not include any other text! + """ + return await ask(prompt) + + async def visit(node): + children = node.get('nodes') or [] + if children: + await asyncio.gather(*(visit(child) for child in children)) + if node.get('summary'): + return + node['summary'] = await (parent_summary(node) if children else leaf_summary(node)) + + await asyncio.gather(*(visit(root) for root in structure)) + return structure + + def create_clean_structure_for_description(structure): """ Create a clean structure for document description generation, From e55f92d01f32d602911e9f0f0cb89fbb60f82a8f Mon Sep 17 00:00:00 2001 From: Ray Date: Fri, 31 Jul 2026 20:07:22 +0800 Subject: [PATCH 09/23] Sync PageIndex Flash from private branch --- pageindex/flash/api.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/pageindex/flash/api.py b/pageindex/flash/api.py index e1d8cdd96..a0cb8d317 100644 --- a/pageindex/flash/api.py +++ b/pageindex/flash/api.py @@ -75,10 +75,8 @@ def _thin(structure): async def _summarize(structure, page_list, model): - from ..utils import add_node_text, generate_summaries_for_structure, remove_structure_text - add_node_text(structure, page_list) - await generate_summaries_for_structure(structure, model=model) - remove_structure_text(structure) + from ..utils import summarize_tree + await summarize_tree(structure, page_list, model=model) def _optimize(structure, page_texts, do_expand, model): From ff4727d423a38b44af52ed52805f94eaefd7ef58 Mon Sep 17 00:00:00 2001 From: Ray Date: Fri, 31 Jul 2026 20:16:38 +0800 Subject: [PATCH 10/23] Trim README --- README.md | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/README.md b/README.md index 72e83e489..59d674b12 100644 --- a/README.md +++ b/README.md @@ -205,24 +205,7 @@ python3 run_pageindex.py --md_path /path/to/your/document.md > python3 run_pageindex.py --flash --pdf_path /path/to/your/document.pdf > ``` > -> In Flash output, a node's `end_index` covers its whole section, subsections included; the pages between a parent's heading and its first child run from `start_index` to the first child's `start_index`. - -### Tree Optimization *(preview)* - -`--optimize` refines a Flash tree to minimize **worst-case search cost**: how many pages a search must read in the worst case, counting one page per routing step. Two operators run until the tree stops changing: - -- **merge** — collapses any subtree whose structure costs more to route through than its pages cost to read. Deterministic, no LLM. The removed titles are kept on the parent as `key_items`. -- **expand** — splits a large section into the subsections actually printed on its pages, when routing into them is cheaper than scanning the section. Uses the summary model for one lookahead call per large section. - -```bash -# merge + expand, then summaries -python3 run_pageindex.py --flash --optimize --pdf_path /path/to/your/document.pdf - -# merge only: deterministic, no extra LLM calls -python3 run_pageindex.py --flash --optimize-merge-only --pdf_path /path/to/your/document.pdf -``` - -The output gains an `optimize` key reporting merge/expand counts and before/after search-cost metrics. Node summaries are generated after optimization, so they always describe the final tree. +> A node's `end_index` covers its whole section, subsections included. Add `--optimize` to refine the tree for search cost before summaries (`--optimize-merge-only` for the LLM-free variant). ## 🚀 Agentic Vectorless RAG: An Example From 59c97256baad7c3d55cd61c9b5b8cd2067efbd30 Mon Sep 17 00:00:00 2001 From: Ray Date: Fri, 31 Jul 2026 20:17:32 +0800 Subject: [PATCH 11/23] Update README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 59d674b12..fbaceb2e5 100644 --- a/README.md +++ b/README.md @@ -205,7 +205,7 @@ python3 run_pageindex.py --md_path /path/to/your/document.md > python3 run_pageindex.py --flash --pdf_path /path/to/your/document.pdf > ``` > -> A node's `end_index` covers its whole section, subsections included. Add `--optimize` to refine the tree for search cost before summaries (`--optimize-merge-only` for the LLM-free variant). +> Add `--optimize` to refine the tree structure for more efficient retrieval (`--optimize-merge-only` for the no-LLM variant). ## 🚀 Agentic Vectorless RAG: An Example From 2f598201f1f6e16559bf6aac934729f748edac64 Mon Sep 17 00:00:00 2001 From: Ray Date: Fri, 31 Jul 2026 20:28:30 +0800 Subject: [PATCH 12/23] Sync PageIndex Flash from private branch --- pageindex/flash/api.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/pageindex/flash/api.py b/pageindex/flash/api.py index a0cb8d317..a43ab95be 100644 --- a/pageindex/flash/api.py +++ b/pageindex/flash/api.py @@ -74,9 +74,9 @@ def _thin(structure): write_node_id(structure) -async def _summarize(structure, page_list, model): +async def _summarize(structure, page_list, model, concurrency=32): from ..utils import summarize_tree - await summarize_tree(structure, page_list, model=model) + await summarize_tree(structure, page_list, model=model, concurrency=concurrency) def _optimize(structure, page_texts, do_expand, model): @@ -100,8 +100,8 @@ def _optimize(structure, page_texts, do_expand, model): def page_index_flash(pdf, summary=True, summary_model=None, optimize=False, optimize_expand=True, - optimize_model=None) -> dict: - """Build a PageIndex tree structure from a PDF using layout statistics, without an LLM. Args: pdf: path to a PDF file (``str`` or ``pathlib.Path``) or an in-memory binary stream (``io.BytesIO``). summary: if True, generate LLM summaries for each node (requires ``summary_model``). summary_model: the LLM model identifier to use for summary generation. optimize: if True, refine the tree for search cost (merge + expand) before summaries. optimize_expand: if False, optimization only merges - deterministic, no LLM calls. optimize_model: the LLM model for expand (defaults to the summary model). Returns: dict with keys ``doc_name``, ``doc_title``, ``structure`` (a list of nested ``{"title", "start_index", "end_index", "nodes"}`` dicts; page indexes are 1-based) and ``has_abstract_or_references_section`` (True when a top-level entry is an abstract or references heading). With ``optimize`` an ``optimize`` key reports merge/expand counts and before/after search-cost metrics. """ + optimize_model=None, summary_concurrency=32) -> dict: + """Build a PageIndex tree structure from a PDF using layout statistics, without an LLM. Args: pdf: path to a PDF file (``str`` or ``pathlib.Path``) or an in-memory binary stream (``io.BytesIO``). summary: if True, generate LLM summaries for each node (requires ``summary_model``). summary_model: the LLM model identifier to use for summary generation. optimize: if True, refine the tree for search cost (merge + expand) before summaries. optimize_expand: if False, optimization only merges - deterministic, no LLM calls. optimize_model: the LLM model for expand (defaults to the summary model). summary_concurrency: maximum simultaneous summary model calls. Returns: dict with keys ``doc_name``, ``doc_title``, ``structure`` (a list of nested ``{"title", "start_index", "end_index", "nodes"}`` dicts; page indexes are 1-based) and ``has_abstract_or_references_section`` (True when a top-level entry is an abstract or references heading). With ``optimize`` an ``optimize`` key reports merge/expand counts and before/after search-cost metrics. """ result = extract_toc(_validate_pdf(pdf)) structure = result.get("structure", []) if optimize and structure: @@ -118,7 +118,8 @@ def page_index_flash(pdf, summary=True, summary_model=None, summary_model = getattr(cfg, 'summary_model', None) or cfg.model page_texts = result.pop("page_texts", []) page_list = [(text, 0) for text in page_texts] - asyncio.run(_summarize(structure, page_list, summary_model)) + asyncio.run(_summarize(structure, page_list, summary_model, + concurrency=summary_concurrency)) else: result.pop("page_texts", None) return result From 62e5a5619668cce57303f57ea7697588f0d719d4 Mon Sep 17 00:00:00 2001 From: Ray Date: Fri, 31 Jul 2026 20:31:52 +0800 Subject: [PATCH 13/23] Sync PageIndex Flash from private branch --- pageindex/flash/api.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pageindex/flash/api.py b/pageindex/flash/api.py index a43ab95be..adff94cb9 100644 --- a/pageindex/flash/api.py +++ b/pageindex/flash/api.py @@ -74,7 +74,7 @@ def _thin(structure): write_node_id(structure) -async def _summarize(structure, page_list, model, concurrency=32): +async def _summarize(structure, page_list, model, concurrency=None): from ..utils import summarize_tree await summarize_tree(structure, page_list, model=model, concurrency=concurrency) @@ -100,8 +100,8 @@ def _optimize(structure, page_texts, do_expand, model): def page_index_flash(pdf, summary=True, summary_model=None, optimize=False, optimize_expand=True, - optimize_model=None, summary_concurrency=32) -> dict: - """Build a PageIndex tree structure from a PDF using layout statistics, without an LLM. Args: pdf: path to a PDF file (``str`` or ``pathlib.Path``) or an in-memory binary stream (``io.BytesIO``). summary: if True, generate LLM summaries for each node (requires ``summary_model``). summary_model: the LLM model identifier to use for summary generation. optimize: if True, refine the tree for search cost (merge + expand) before summaries. optimize_expand: if False, optimization only merges - deterministic, no LLM calls. optimize_model: the LLM model for expand (defaults to the summary model). summary_concurrency: maximum simultaneous summary model calls. Returns: dict with keys ``doc_name``, ``doc_title``, ``structure`` (a list of nested ``{"title", "start_index", "end_index", "nodes"}`` dicts; page indexes are 1-based) and ``has_abstract_or_references_section`` (True when a top-level entry is an abstract or references heading). With ``optimize`` an ``optimize`` key reports merge/expand counts and before/after search-cost metrics. """ + optimize_model=None, summary_concurrency=None) -> dict: + """Build a PageIndex tree structure from a PDF using layout statistics, without an LLM. Args: pdf: path to a PDF file (``str`` or ``pathlib.Path``) or an in-memory binary stream (``io.BytesIO``). summary: if True, generate LLM summaries for each node (requires ``summary_model``). summary_model: the LLM model identifier to use for summary generation. optimize: if True, refine the tree for search cost (merge + expand) before summaries. optimize_expand: if False, optimization only merges - deterministic, no LLM calls. optimize_model: the LLM model for expand (defaults to the summary model). summary_concurrency: maximum simultaneous summary model calls; None uses the library default. Returns: dict with keys ``doc_name``, ``doc_title``, ``structure`` (a list of nested ``{"title", "start_index", "end_index", "nodes"}`` dicts; page indexes are 1-based) and ``has_abstract_or_references_section`` (True when a top-level entry is an abstract or references heading). With ``optimize`` an ``optimize`` key reports merge/expand counts and before/after search-cost metrics. """ result = extract_toc(_validate_pdf(pdf)) structure = result.get("structure", []) if optimize and structure: From 3670d497b6edc1917e6ee9dd360f4859616b9558 Mon Sep 17 00:00:00 2001 From: Ray Date: Fri, 31 Jul 2026 20:31:52 +0800 Subject: [PATCH 14/23] Define summary constants --- pageindex/utils.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/pageindex/utils.py b/pageindex/utils.py index 456bfac27..0704a00ae 100644 --- a/pageindex/utils.py +++ b/pageindex/utils.py @@ -640,7 +640,12 @@ async def generate_summaries_for_structure(structure, model=None): return structure -def get_intro_text(node, pdf_pages, max_pages=3): +SUMMARY_CONCURRENCY = 32 # simultaneous summary model calls +SUMMARY_RAW_TEXT_TOKENS = 200 # leaves under this reuse their raw text as the summary +SUMMARY_INTRO_MAX_PAGES = 3 # cap on leading pages fed into a parent summary + + +def get_intro_text(node, pdf_pages, max_pages=SUMMARY_INTRO_MAX_PAGES): """Pages of the node covered by no child: from its start to just before the first child starts. Empty when the first child opens on the node's own page.""" children = node.get('nodes') or [] @@ -651,14 +656,15 @@ def get_intro_text(node, pdf_pages, max_pages=3): return get_text_of_pdf_pages(pdf_pages, node['start_index'], end) -async def summarize_tree(structure, pdf_pages, model=None, small_node_tokens=200, - max_intro_pages=3, concurrency=32): +async def summarize_tree(structure, pdf_pages, model=None, + small_node_tokens=SUMMARY_RAW_TEXT_TOKENS, + max_intro_pages=SUMMARY_INTRO_MAX_PAGES, concurrency=None): """Bottom-up summaries: leaves from their own pages, parents composed from child summaries plus the pages no child covers. A parent's summary describes its whole subtree (end_index union semantics). Nodes that already carry a summary are left untouched; leaves under `small_node_tokens` use their raw text as the summary without a model call.""" - semaphore = asyncio.Semaphore(concurrency) + semaphore = asyncio.Semaphore(concurrency or SUMMARY_CONCURRENCY) async def ask(prompt): async with semaphore: From 83f8e5d23c04d30933497a9f7376f904672cf2cc Mon Sep 17 00:00:00 2001 From: Ray Date: Fri, 31 Jul 2026 20:35:48 +0800 Subject: [PATCH 15/23] Raise summary concurrency to 64 --- pageindex/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pageindex/utils.py b/pageindex/utils.py index 0704a00ae..38e1096d5 100644 --- a/pageindex/utils.py +++ b/pageindex/utils.py @@ -640,7 +640,7 @@ async def generate_summaries_for_structure(structure, model=None): return structure -SUMMARY_CONCURRENCY = 32 # simultaneous summary model calls +SUMMARY_CONCURRENCY = 64 # simultaneous summary model calls SUMMARY_RAW_TEXT_TOKENS = 200 # leaves under this reuse their raw text as the summary SUMMARY_INTRO_MAX_PAGES = 3 # cap on leading pages fed into a parent summary From 58c12e596c0ba68cb616d54f60510d6da2725a8d Mon Sep 17 00:00:00 2001 From: Ray Date: Sat, 1 Aug 2026 17:52:19 +0800 Subject: [PATCH 16/23] Sync PageIndex Flash from private branch --- pageindex/flash/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pageindex/flash/api.py b/pageindex/flash/api.py index adff94cb9..804169890 100644 --- a/pageindex/flash/api.py +++ b/pageindex/flash/api.py @@ -101,7 +101,7 @@ def _optimize(structure, page_texts, do_expand, model): def page_index_flash(pdf, summary=True, summary_model=None, optimize=False, optimize_expand=True, optimize_model=None, summary_concurrency=None) -> dict: - """Build a PageIndex tree structure from a PDF using layout statistics, without an LLM. Args: pdf: path to a PDF file (``str`` or ``pathlib.Path``) or an in-memory binary stream (``io.BytesIO``). summary: if True, generate LLM summaries for each node (requires ``summary_model``). summary_model: the LLM model identifier to use for summary generation. optimize: if True, refine the tree for search cost (merge + expand) before summaries. optimize_expand: if False, optimization only merges - deterministic, no LLM calls. optimize_model: the LLM model for expand (defaults to the summary model). summary_concurrency: maximum simultaneous summary model calls; None uses the library default. Returns: dict with keys ``doc_name``, ``doc_title``, ``structure`` (a list of nested ``{"title", "start_index", "end_index", "nodes"}`` dicts; page indexes are 1-based) and ``has_abstract_or_references_section`` (True when a top-level entry is an abstract or references heading). With ``optimize`` an ``optimize`` key reports merge/expand counts and before/after search-cost metrics. """ + """Build a PageIndex tree structure from a PDF using layout statistics, without an LLM. Args: pdf: path to a PDF file (``str`` or ``pathlib.Path``) or an in-memory binary stream (``io.BytesIO``). summary: if True, generate LLM summaries for each node (requires ``summary_model``). summary_model: the LLM model identifier to use for summary generation. optimize: if True, refine the tree for search cost (merge + expand) before summaries. optimize_expand: if False, optimization only performs deterministic merge; summary generation is unchanged. optimize_model: the LLM model for expand (defaults to the summary model). summary_concurrency: maximum simultaneous summary model calls; None uses the library default. Returns: dict with keys ``doc_name``, ``doc_title``, ``structure`` (a list of nested ``{"title", "start_index", "end_index", "nodes"}`` dicts; page indexes are 1-based) and ``has_abstract_or_references_section`` (True when a top-level entry is an abstract or references heading). With ``optimize`` an ``optimize`` key reports merge/expand counts and before/after search-cost metrics. """ result = extract_toc(_validate_pdf(pdf)) structure = result.get("structure", []) if optimize and structure: From 93691d2d244148bc98213d48b4a250b04462fcc0 Mon Sep 17 00:00:00 2001 From: Ray Date: Sat, 1 Aug 2026 17:52:54 +0800 Subject: [PATCH 17/23] Parse summary replies without extract_json --- pageindex/utils.py | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/pageindex/utils.py b/pageindex/utils.py index 38e1096d5..67f9a8ddd 100644 --- a/pageindex/utils.py +++ b/pageindex/utils.py @@ -656,6 +656,35 @@ def get_intro_text(node, pdf_pages, max_pages=SUMMARY_INTRO_MAX_PAGES): return get_text_of_pdf_pages(pdf_pages, node['start_index'], end) +def parse_summary(reply): + """The `summary` field of a model reply, or the reply itself when there is no + such field. Not extract_json: that rewrites `None` to `null` and collapses + whitespace in replies that parse as written.""" + if not isinstance(reply, str) or not reply.strip(): + return "" + text = reply.strip() + if '```' in text: + text = re.sub(r'^.*?```(?:json)?\s*', '', text, flags=re.S).split('```')[0] + start, end = text.find('{'), text.rfind('}') + if start != -1 and end > start: + obj = text[start:end + 1] + collapsed = ' '.join(obj.split()) + parsed = None + # repairs, tried only once the reply fails to parse as written + for candidate in (obj, collapsed, collapsed.replace(',]', ']').replace(',}', '}')): + try: + parsed = json.loads(candidate) + break + except json.JSONDecodeError: + continue + if isinstance(parsed, dict) and 'summary' in parsed: + summary = parsed['summary'] + if isinstance(summary, list): + summary = ' '.join(str(item).strip() for item in summary if str(item).strip()) + return str(summary).strip() if summary else "" + return reply.strip() + + async def summarize_tree(structure, pdf_pages, model=None, small_node_tokens=SUMMARY_RAW_TEXT_TOKENS, max_intro_pages=SUMMARY_INTRO_MAX_PAGES, concurrency=None): @@ -669,7 +698,7 @@ async def summarize_tree(structure, pdf_pages, model=None, async def ask(prompt): async with semaphore: response = await llm_acompletion(model, prompt) - return (extract_json(response) or {}).get('summary') or "" + return parse_summary(response) async def leaf_summary(node): text = get_text_of_pdf_pages(pdf_pages, node['start_index'], node['end_index']) From 1273697c9c16a9d15f4340ca5ec2f85f8c277651 Mon Sep 17 00:00:00 2001 From: Ray Date: Sat, 1 Aug 2026 17:52:54 +0800 Subject: [PATCH 18/23] Rework the optimize and summary model flags --- README.md | 2 +- run_pageindex.py | 23 +++++++++++++---------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index fbaceb2e5..752f91dec 100644 --- a/README.md +++ b/README.md @@ -205,7 +205,7 @@ python3 run_pageindex.py --md_path /path/to/your/document.md > python3 run_pageindex.py --flash --pdf_path /path/to/your/document.pdf > ``` > -> Add `--optimize` to refine the tree structure for more efficient retrieval (`--optimize-merge-only` for the no-LLM variant). +> Add `--optimize` to refine the tree structure for more efficient retrieval (`--optimize merge` skips the LLM expansion pass). ## 🚀 Agentic Vectorless RAG: An Example diff --git a/run_pageindex.py b/run_pageindex.py index 95ca782bc..d7cfc135f 100644 --- a/run_pageindex.py +++ b/run_pageindex.py @@ -11,12 +11,14 @@ parser.add_argument('--pdf_path', type=str, help='Path to the PDF file') parser.add_argument('--md_path', type=str, help='Path to the Markdown file') parser.add_argument('--flash', action='store_true', help='Use PageIndex Flash (with --pdf_path)') - parser.add_argument('--optimize', action='store_true', - help='Refine the tree for search cost: merge + LLM expand (PDF only)') - parser.add_argument('--optimize-merge-only', action='store_true', - help='Refine the tree with merge only, no LLM calls (PDF only)') + parser.add_argument('--optimize', nargs='?', const='full', choices=['full', 'merge'], + default=None, + help='Refine the tree for search cost: merge + LLM expand; ' + 'pass `merge` to skip the expansion pass (PDF only)') parser.add_argument('--model', type=str, default=None, help='Model to use (overrides config.yaml)') + parser.add_argument('--summary-model', type=str, default=None, + help='Model for node summaries (defaults to --model, then config.yaml)') parser.add_argument('--toc-check-pages', type=int, default=None, help='Number of pages to check for table of contents (PDF only)') @@ -48,8 +50,8 @@ raise ValueError("Either --pdf_path or --md_path must be specified") if args.pdf_path and args.md_path: raise ValueError("Only one of --pdf_path or --md_path can be specified") - if (args.optimize or args.optimize_merge_only) and not (args.pdf_path and args.flash): - raise ValueError("--optimize / --optimize-merge-only require --flash with --pdf_path") + if args.optimize and not (args.pdf_path and args.flash): + raise ValueError("--optimize requires --flash with --pdf_path") if args.pdf_path: # Validate PDF file @@ -60,7 +62,7 @@ if args.flash: from pageindex.flash import page_index_flash - if args.optimize: + if args.optimize == 'full': from pageindex.tree_optimize import default_model from pageindex.utils import _is_openai_model expand_model = args.model or default_model() @@ -68,9 +70,10 @@ raise SystemExit(f"OPENAI_API_KEY is not set (expand model: {expand_model}).") toc_with_page_number = page_index_flash( args.pdf_path, - optimize=args.optimize or args.optimize_merge_only, - optimize_expand=args.optimize, + optimize=args.optimize is not None, + optimize_expand=args.optimize == 'full', optimize_model=args.model, + summary_model=args.summary_model or args.model, ) if 'optimize' in toc_with_page_number: o = toc_with_page_number['optimize'] @@ -159,4 +162,4 @@ with open(output_file, 'w', encoding='utf-8') as f: json.dump(toc_with_page_number, f, indent=2, ensure_ascii=False) - print(f'Tree structure saved to: {output_file}') \ No newline at end of file + print(f'Tree structure saved to: {output_file}') From 71ce87c196b79f5251b96d181299ea787e78b9b0 Mon Sep 17 00:00:00 2001 From: Ray Date: Mon, 3 Aug 2026 00:53:49 +0800 Subject: [PATCH 19/23] Sync PageIndex Flash from private branch --- pageindex/flash/README.md | 53 +++++++++-------------- pageindex/flash/assets/cost_vs_pages.png | Bin 65085 -> 0 bytes 2 files changed, 21 insertions(+), 32 deletions(-) delete mode 100644 pageindex/flash/assets/cost_vs_pages.png diff --git a/pageindex/flash/README.md b/pageindex/flash/README.md index 85b4b5ede..29bafdb01 100644 --- a/pageindex/flash/README.md +++ b/pageindex/flash/README.md @@ -1,22 +1,32 @@ # PageIndex Flash -Builds a PageIndex tree structure from a PDF using layout statistics alone. -No LLM, no API key, no OCR, no network. Runs in seconds, fully offline. +Builds the PageIndex tree structure from a PDF using layout statistics without +LLM. Augmenting the tree with summaries and refining it for retrieval needs an +LLM. ## Usage +### Python + ```python from pageindex.flash import page_index_flash -tree = page_index_flash("paper.pdf") +tree = page_index_flash("paper.pdf", summary=False) # structure only +tree = page_index_flash("paper.pdf") # + a summary per node +tree = page_index_flash("paper.pdf", optimize=True) # + retrieval refinement ``` +Takes a file path or an `io.BytesIO` stream and returns the tree as a dict. +Summaries are on by default and need an API key. + +### Command line + ```bash python3 run_pageindex.py --pdf_path document.pdf --flash +python3 run_pageindex.py --pdf_path document.pdf --flash --optimize ``` -Accepts a path (`str` or `pathlib.Path`) or an `io.BytesIO` stream. Raises on a -missing, non-PDF, encrypted, empty, or unreadable file. +Writes the tree to `results/_structure_flash.json`. ## Output @@ -24,22 +34,21 @@ missing, non-PDF, encrypted, empty, or unreadable file. { "doc_name": str, "doc_title": str, + "has_abstract_or_references_section": bool, "structure": [ { "title": str, "node_id": str, # 4-digit, zero-padded - "start_index": int, + "start_index": int, # 1-based, inclusive "end_index": int, - "key_items": [str], # with --optimize: titles of merged-away subsections - "nodes": [...], # absent on leaf nodes + "summary": str, # with summary + "key_items": [str], # with optimize: titles merged away + "nodes": [...], # absent on leaves } ], } ``` -Page indexes are 1-based. `nodes` nests the same shape recursively. Without -`--optimize` the extracted tree is returned as-is. - ## Benchmark Nine PDFs, each run end to end with tree optimization: PDF parse, layout @@ -47,14 +56,6 @@ outline, merge, LLM expand, then a summary for every node. ![Time against document length](assets/time_vs_pages.png) -![Cost against document length](assets/cost_vs_pages.png) - -Both scale close to linearly with length, at 218 s and $0.85 per 1,000 pages. -Two qualifiers. Cost follows node count a little more closely than page count, -$0.0007 to $0.0016 per node, so a densely structured document costs more than -its length suggests. And wall clock flattens past roughly 700 pages, where -summary concurrency rather than length becomes the limit. - | Document | Pages | Input tokens | Output tokens | |---|---:|---:|---:| | Bitcoin whitepaper | 9 | 8,715 | 4,673 | @@ -68,16 +69,4 @@ summary concurrency rather than length becomes the limit. | Machine Learning: A Probabilistic Perspective | 1,098 | 1,587,265 | 646,958 | | **Total** | **2,985** | **3,751,599** | **1,392,588** | -Measured with `gpt-5.6-luna` at $0.20 / $1.20 per million input / output tokens, -priced cold with no prompt-cache discount. - -## Limits - -- Scanned PDFs without embedded text are not supported. -- Encrypted PDFs need preprocessing first. -- Headings drawn as vector paths, or very decorative layouts, can be missed. -- Titles are taken from the document text as-is. - -## Dependencies - -`pypdfium2`, `PyPDF2`, `regex`, `sortedcontainers`. +Measured with `gpt-5.6-luna`. diff --git a/pageindex/flash/assets/cost_vs_pages.png b/pageindex/flash/assets/cost_vs_pages.png deleted file mode 100644 index 177139de27965f2441e82ecca944a0fc632b5bb4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 65085 zcmdqJg;&$>9|sB|q99nPUs|yc1eETSP-&1Z6&MZD9ilXX(%|S$>1HAZQllpf7>MK; z1I8FI#_ltJH~xcr?zx`haqeu-=d&l?@rqB3j+PoDJu5vG6&2$X^~ZWtRHx~vsAw*p zJp}Sb{+ASqoN{JJbA2W;P+{Bjy6Rf zvHx?2CZa?6b*av5vBX4Iv8#@o)!e)GP9tAO=8bm&_n*%~^{P?|-u+?r?F%Ih2i_xR zYkd4QcsqIUFVTe0NoX`XJDZQV)uk<$3ttrmm!zV)VY{roclzJUuy5QF|6VA>-unOU zizMzyCJx8l<$?6YJN*1Z235wR?7p+F?fQ~#u8ia>-@R%ca=3irpXZ;dE*r4-d0e-{ z$0eDqZ5`gf{QbJs;8mRBc#*@n$VyQlwhPgX?Z9@k5eTMj9>wH9N%ApHm~?D&a*~8Y zPohG}vOgij*4InN*ig7EA!GLLC{FMeVa_)f+AIh=78*@{WB%5K<|{|8Jrr8;!@t=m zMvm!z(ul9xn54(w=;==XhQsA_tIg%=mU$toP*PL5d7DSUJw|nh(38Vb|C7U2%>-7_ z!8_^ZAJ=}>u?f;%{2NZk`H+^LS$%z7U1dh>_y}FQe*`N5|>^ z_fjBfs{?yiBJA&2neHu5OcO#iD82yM6@V84OOlu=7u6O}|8g#q;}BqrZKD73^PDam#&yH{J9W?tWPRU`qJAVk-ewZH8*QvbNjt zgoF*vrlvWQ;W$}wC*UI$)wBYf`t3x$?K2!0{)kN88`sX%S2*Pqs3MJeI!6;`qtjrg znQhSufpdD2NrZa=9^gBV-Kkzc12)P}(4MQqwp!HZ9@-85XEt>UVeZL@q7EzanDa(V z!?;2Y@=dyf{0r{UQ6({+rn@+$tH!Ym>-I)ZB_&S7LSg>6A#oQ>ysD%Qzt^vicQhO9 zhBldct4tb%KJ``<8WuJ+yC7#)E10yKRwL*xG!y)#{Zuz>DzZvDw%P|oTxKRxc%S!| zShqM%_D>yDS#*fqwLn_Lt`24jyJV_Vn>IOLxjSj<@HVQp`-ry}F)q`#H};GKe;&~$ z43`<#$ar{j!$GfN^ImSyOD)M|=IkHkh*31ud&*=5=>}swWS5-}eqW#xmt{MU_AY4l zt7r&N(`CK_zN))f{+Ru{BzWS>~GkZ|^ zf~-xtEWhy9VOA|y(6-{l{a=pP{785N@+0)+_tSn1=htq>EJkrL5hbUp z%^-oN*gXdyi4pUir|W&X5Z6sIiG`V8VA=26~b-TE&cLYWCOem=JKyUFt$z~h0Bv?_qOJc2IYF+1$vKiyzUwo z+xE=Lc;m+lP%Mtd(_EAB zs*l-&?+q8ya_t2d(=b(45icK+JG&45Fm!Aonjd3p567y_#BpQK1T(t=36a5;+iuDO z$#SJabWMZpE^SjEI(#tnL@C+8{YK=?YPX4R66{#QhdEu1ZsL}7$J|7xfI(T(eXH(H z(x79~f9t{>F0=eCvoEtm9A3(oPN`*)#pnGBP1?O2BC%cWi%M)|lOAQZ&ryMM?cNw4 zi#ygPr0!dzZnHxj?gJf#c4J$0rq}&AWc)@#)Ogt@Jqm)wFdg1ov(ssjkB@Q+uf_#` zs;WVDHfrf6&#Vk(BW+F4@ke+x^1KXwivIhMc$b@@nz=hV?TVH}4~$=jrvD5h>+j`a z%dUWuQ1S_Nc%%HuL9c+m_r^qB?sN2uR9jVHxDR&TB`0{_qytsuUrZS34xf7FxiT2z zD7}z%6tX`JS^0KFyOG#vn!eW2aquTjIp>BKd~!9JkxlGd6SBMcx%jMm1j+emXkEM( zyWX~%!w?vMc>w=ubGB_<&L|NfvKBZdrjFEhQBxk$_syP49u7Uhagmp}N8K!tms@gc zQD0Q9Ti7Hj{^^fOd1%vjkz@Jy03|w3dV&zmMfU7Zxu3js0@A^pQLUvc`PEko*Y@?2 z&)7PG--!Dr^+i~y@BBGwj;woI zby-H1;Qh0$Sg>{Xm*Khcn}KtoWYP=N>fvfGp}4sCU65sW&|#2Z_d!8zAYp5tY}(}b zE+`>^P5h|cYyI-;90J&rEk2kTFSzXC-(*svMqeF72zyF_@dt-B3wv|+78kcfd5vl|vqhZ@f1~`izjp-h)EHeC zv}E5EWfgU_NgwBZUhy`l>kTc#QOMEyJ=EV{$-&G0sSLz!@*zBQELf{Lbe80c3ih2C z&{iEV@IffnWrWsyqYbTUExWk8Lx-|NgM}f(=iAQvhq9S6y_ec3`&>I-^yCU93I#8k zzy5x8|Mg_WbB2y?TnN|V6q;qa!?zY&)i4};kjW-uzhKtvY|^(%i8I|w@95tJyM~{l zRjP4Ev_7|XYizVrc(u! z1GQO&`~MjU1sZTL<@*0*rh@Jb~vUy1|t-h4iky zU1{KHuU=Liuf$AlOjcqtMWE10nNb!|=>4%EA7)#_>3G%E5ffiqdat#S9@RHjX^qZA z7j#SURj)w%xzDOVGKXae<_&Hrx$!0^qregc-9QzzXu=VcCs`RMTevL2ENcW=dJIdo zHjVW1(c81g4ch%hP3Z7=>rNg3o00^9q>?^ff)0)D7CLkJreRWzEbCzBNStr(Kdn8i z^v76Odfzn|$R4iR@U1B8sy|zhI=wzppXZ%6uZ>C`=C%*Mg!X=-Rh8%^_5?R!YrYo$ zcIuAle#-ZP26-u`5)|P_0vqP&#w-t078CTtsL6lO)bY$pkj}Cfd!vx3u;DZ7O-=D@ z8hQ4zCGcY5eAZD_FEJH#D0K4Ic^ARGVEce387G#BX-$0FA*Kbj=?%3VhfHD(A^ZR$aTOtP7C^n~Ju?bzvt- z#F5C9h@1BrQ>4JCP-R2b(Qs49=xz-@c8bmv=9GySAeh3+p0yH(_e^KRN5#x>x{!4d ztJFt@!qHnq??%~?X=BZ(G#GlZt!wa_6h(pIC!x<-ks@6fAPJHbxUg^1BcM~(v6 zj1TQNhV`nV*EJI&_4|?@=#wczm3Q4 z7b@jlKTzFm#J_aJK6B!fIWBWRBtk1)5_Ix9D9H9r+}q7GzF zR8XYWt7=I|5#J%R6fgWW#I8Efi@j*s@+a#|)X1ac5Tt2j)3IeRe$uDy{eTYcHG}jG zBM1{u|8G$5kUc52INhv#z@9xhm~@24amWQp%sH(&C&t}CYxsONb8Y)hW=%Kuh{1xc zZhbhb*$E+V!}J>a^JCDi1gXSb!{_3?K1kKBkCzgkrVF@m>390QW|M_Gu7pV^U)ylr zD^9oE2pz;Dthn5Bxn~7T%hy&vzvvy4C4&}+F*-gnR=uM^cuT`%nl=cqVUJyPhEoc`wN4!R?vngs9nnkL%Z79P*X>y}}84bdNss}s4Wy8>HkK|Vh@JnEt2dTTClw70%Qo=ucH zvj0Nrfe6hsyEqAd?_}bXv4{B*GOlRLJenigc-cv34N?=j>`AM|*!mZ}k<{bm8kq~W zkEb=aP9V9#VjF|u>RzhzL$542{T9MAY9oZQul6lW!F5d}S`jNoU z8j4#P>aMpPu**2~!cQ6zu@~lD9ZV1=t_02_jUJGyR^jv$wtxFRTxy}|pO|ciJFN~w zPY9#72iuD;m*~Z^)i*71Gva!567M zp3evkOwhR>k$H~kH1?t2z=N+Y#$Hsc_hlzR5gho{RRTZP$~>h`&B<{*S+4&RHhMOv zW#+}`)2S{v=Sk^ygCe)hw2(&J|`8=LaV?me!8T7^PtHEUn34oT2IR&i1BMw!V+`x8GMgMv9}MtpfF`Q{wtX$>yugq2oB%b zMftv+5{Dl`67HaxkA#L`-{&wr6yg&ezw~K~4J4wfDYXwg)zg8W+25z3!MqV^1m`2v zu=@(*th~`TdGYQ8Nok7W8L7uq3Maf#b-?O>{ zTOMps=_8RkX#y4-s9#1KZInu%DBlaV{I&gHuN&8E_OzB$+bxQd7R#J_WJ5t|`G}FJ z6F+5kiH7U}`gS5$q24DQ+jEPjie^Gu;bqHrgJCW3w)hOik-4Mb2 zsluJ}OnYmiy=2*_Y`IbDz_L8+2UDJ*$2(D>oEwi8{Rk`#Z0sW^#_rZ%bgf5Zx4tq6 z?tQ0WJg1+e7pEj@uE+aPkC<979C{K&G%ilPKc`wQV~Lq*5>Axe&+y+UFC7zog3~Kx z)1{A3AKOmlUK%s)Ev~))T*!PQK`(6fV%*cVSP>4#g+;yS$*Oym2E2JCYWX~}BgMh@ zuZm01P8JJQT+)*eM+JE*9Cf63(;;Suv)iw0Fh-i6i_*LGJZrHSYr0;abxn6VN^cw$ zOt2urSl`MXEJWNjaUcd`y9hJO+L|Fkru$0#q4Q__p0+TmI6#g#)-Hdl!9vpo-pMc* zr?op6R`}TQKy@5awtO$Xf0jRv?Lh{qJH*xOK(p%FCI%M9P9e6{8OzPvt*h>n_zV62 z>Jk2|mt?!fBHM3+&X@PQ`#=sWXJnz5T5)}>o?NL$=H=bjPpi)(*tN#jx~2JhLHV8> zLzfCbGdu0~%{2K_2~VLu=KT<#visS3`g265IgU@=9C6+{K0L)UqI3FfdPR$xU9hf8 zw^ZK-n-^Uca?8}(ZUzEMDgzoLe5D*LN7aekO2 zprmWsOvLco&LL+9~O^xSLR;Fq_-QE3RB^NQJ+H|O~1V`{L({9f$8MJC=^#ZO{Y z6&(rRIzBSs{hMAbpQ1XdX^f~$%@wn&VDk=-*whQocdRaGd;CnAsp9qc?&{p(&RDF) zTww3cR4?y;B}}_B7yTV9%hDqS<`Wvz9WCa3Ib#VO`vO-+N1j^AGQ3e^I#-g_t0NsO za5j&zLd=xjC%PrO`Hh-D-bGDVc$LnAHb8y`3kOGh{}brD9QUsnZhW<>8a67Ez$`*< zDb|TL7j&ar)-k}c!Ed@W)tuXMSSp(z8&$O6nRkV=c$dSYH;m|**w?J!8)5ehWFZfx z6sH$S(7-5mCY8BPi|re;LV3%h39Of5eL1eLi&uQ0nQiAw>$$jdrtP^IN6f~ND0J&~ zR5PsR#5}wsUg~WJDnTy7H`5o%NXsT-)l_|+b4VEqpYyrZTKdFiJd84#*;FEs*^j3Kjr>jdAZMl19az4^$$x8Co=l zHUeB#&%ZYk;xj-MeVT<7+f9V>1B_PY8EaTuTRD>LSIWhhYZk4` z;FS(=`&SEBx16)MagI_e9K0HeZ``FjO!8MeT@Nz+MtsXv)n;_sZM8Ka+>B03LV^=1 z>dJcMiqPSYYX$zb>yz)JxLZE3-lNTBIscs~t0MiNps?iSJjbwmqj1T(LGMkd;%k#T zTU1JHeEjM3YYuY$7an-ck+hbsvg=Ig7NJaiR&?zi_s2$mZ@+zSO3g8Ced^AKQv&(J zTIDbFc@~)lm8)|n_xMZG6LqA=ck%`~MV&nv6}W=;M$4uwOhCekLiQknJQYja_mDP&io(K*NQXwEautR zbs%%I3KovuD_YAJYbLl48p=KWTI;O6KUWLuN`>jiyhEGTY<+HDi%;ryDmF*o=*j+Z zb*P?&VIIprMqHy(wD1!Bs#G>eolvmg!!%iKM&~#4XdE(@sn>EPS=Ug*@Vh7H_ool0 z?nu_j?dB8@@;tmV|KX`Qi_z!!DcFwOvrCl^iZ^u>6zD8!_SEQ}@Ulg(O7Td~dgNiA zh#+sbu)j4s^V0hvT2`NPtE;}hgCk)-k8cXbMoaDGW5$v!^$_7v7&mN)F3`9{vP>zc zuKF0Sb=T%y^OG#NDa4+2c8y{F<0(x@8CC4lD_1q9e7TPLZ?AtDfE-gtd5`W={<{%6kW~AG}&OS^vmTU z0mTujPcsd}6mqSUf0Q*F+!0~DtTugGKt;Eyi#Jg>Alql}%AG=vOSV!x5_u79(C@l$ z&3n;);|nQ@c?xfQ?5;{|pP@^U%2{i85X1bx$k{odC?Z`t-~I$U>}5BP?2zl{NlG#+ zPx+GiWqmL)QXpY3FnUsor*hVp3uAKYfAjxc8ZMMPca704Q^{@||LU+~^dy0)sL84z z|FCgxB<3>Wf+Ag6|HF}B`t!ESw;$gQkWKx_gVvtrm2#?K4&aY-RYxpTQDX^(Sq}Je zo%MH-f)irewM#3t^>DAT1yIw*{?@*C;N8&if3L){O+awbVYRh(YYuc3smnWn$gW*p9? zXD%f4qpKhf>Tmw(k%qd`aes7YRNa$m*M^kW6HV}4Ja@)i78*VCq31EyJljcwjORq{ zYLWL5EI81D@z@+WHcAi2jyOX4L^&p?o5#fPa{U!LQZ>UxWifVIyC-*rW-jfdo#qKt z=%E(B*23VphWE07bThM$P*+v%#2Yi;@ow4?tVNLrkF^$_Gyg$>I#9aXLYK8Ar!xe{j;8|dnGk|s9ZIGi zW|R$136afe3QSOu(w4tm?A4<+^rf1gQL{a?4s!{*B2aLx=Eta6dYbM`ab+#TN_CSL zahS=ob;Dxd)A07YN+-saO$ScCTME~GOTIs=Ers%P*q}S&Cgvn_I;ExN zg+A$cHIGSGy9PAjR=bUMeE6rZzbE}l0^Dz0Lx22a%(X7<15qh>VuWCVU8#{wm+oWy zNtEL^ZG7k-v+>E>eU<4C8JA1`og=ejNoSkR=Q-As@B8+nP7250Bc8{OBi9(D`n>4M z>V}1O(tP}%Eh_0g?d)g0+IlX;RTaj@&rq>+jpM(L71+~WPzSgjwfi0!#Qw=)gJUA) za-X4m3$L{-7m5%Yh}vATLZ9vE^0$Ab;S5u|wW?}@ zWvXbt?c?j$FJ5X9WV=tJgo3rW8EWB%vG&cP{g+=8BSvj}ZpRHe>*G6~)0hOSy4~tE zw%>y-rN&RSWJP|G>yEzuhNe#ho8BE3%dgeq&OTH|~G=duGR*t~P)%{qR!kuzM>F9W?wL z!d|4%JBBGFB0aZL0Zq*aS=lhf2e-zz`qf6%44S&rG|l_(02V7ya?Z#7GI^iLG2c=x7c>x(wpfFNnW{im6YSD8Vr>0~j|{ry#r z#~lr2ybL<}_P$>@YKj!X88kbN9;^JBgGc)uz53S@X0ODtJ=K~%C&k00M)xtx(4w>O zUTeeTVq^ugsPOQwA3^ymEqrF@HC$PYPTTj;#H(uicieUN!%GcXkB&J|tO6 zw;aCC$o~Cnoi1l>*{sx*9i<0{%X{)##6SOsYj(tb>z*5ad0x027qo|2HC>MN0z>#C;W@Od$-$^L;y zOK;gu-W-eVYCF0Hmm6c4NNp5RvZsV7H&t}Iz>q|{T@6KPQieAQ_*&Os5ZF;9%mds%( z*7H<}Z1R>6lev))$1kOK8y00w4wg>P^E3`_@Jw~Ovr@h58}B1p*y@hE${H2C@~Syc zxpH_7ORl~R>dX$Iz4R68eo5TQ`(kD$_R;_FCVwks?A47MmXOdnH+{5-8-E8{tad@E zEMLCqSZ4qCtGlC}10^fIt=8}M(IV}NGKAo{VLn9YZuBcE~UeFeXDFRm$lK%nD+S#47=(Z_1%&v#NS z0WhKm6xtMvZ1=8o1N2OH0Dfbd_%wxQk=y||1{478NwpMV$I=Dm94!j0G77 z2=U{j+$XqivZ}a#C%~Va5IF%B-6^t-)eRirjfMZb!XuwOdxnEq^}L}a!JgHEpLobsH~?^Qfj}t{1Yiq&gMm;H)WAQmDIE%Q181C-I~z}46Xt>mb0^5C3tXJrhqY}htVGKX z0;nLIrUpWB8X9UkJQobmhTSeeZgm4i!2#k$);u6o54Z}65#M*k+X16Zeg*L8oTS8^ z5H6N32+xtm)*;YZ5rP4=e1hKxsAMq0GO(IV;eBU9bE_=7W{iNifJ~yu4BWu>IU!!B zFptE|2AU9~o`AizOWR0`&(`5&x$0ceM5n1L2P~r94^WcSaQGT=I38$Qj(82L{`<_H z3jo9yT=K57tzH9h#4e{f5rAKz!l-GE6G!EY|1T<~OBLvwNVTsisCk;8)fO4!0BZod z|AZ(H0H&~2z|)gXh>cv*A;*5itSlo+iDFUXm1~x!#Q$@2lgmChWi;et2ySs7i8le? zTZUN;)cA7LIZq3r0ECZ$!CdFsPw>iR7=@zXruco@fc?#N7@2~wbsg^xP{R`9A7?R< z1mdppt)R5JfFPmU1=&1J-0QOMm;maXXmpNo)#`izx;hLs<4_8eS;<^vSHDzuo~aKo6dc>iqZfLsx93#Y zvM9hX8Fq?(#@Y{bplT-((>m#3ch3_5e|kVh#7h09@ZYxmDd+rhP6YLe)W5hKU83bW zziQyFaB_qe?0B=Ul`PjGK2mVF#B^4`RwQV^q%=VrE)y*u9Hvm8@ zB6bCYa!FogkDm1U=4f#_4$&9w<1;6;CJ+#e?MW1vDF+G`y>A%B?W4!ztjBCRc{i9j zbWXzqvHN#SB53BZQqE53e0d?s3js^NV=fJ{yD^y18ocAheJoxg$T4 zqKZ>91x*JCtk3$6oHFiXJh`u`W9M0v-=L}T@dyUL14uEx`186A1`=s z12NyrP&G^RRCR-5)B-A%5K7Bm)4ALK_6*%))Car9{*0lbK4WqIyOLmfCxq94bbDl}Z zFzkWuJ+op|+ZiEVE_Vk@$L#{C=f(5ox7sTg_4-FoNJJV-wepaSU1Du0X`sYs1D{e~ zzGyOW$+W3s{-{x>cf4#~qsg{rWkxjqH8eSusc8Lq^5x0syRmvjDFtW_dE;D7oQm?` z{Yhhd(uc^fv84$XGv)2JNbRTkhV41eR>v-y9#!=;MLqTRV5}GMn)6?UUKg{47weSA zd}vvbV6pet3(oxE;UjV(^-Y<9da)U=x1M*2Y0d2Jx(j!)h<>p)FJ@xMP1dTIwT4+$ zmTtnqRP zl+gMs2LGY|r2(SSOU)>`9AR|jky}FZSd=iUo-UfBxTFK$STadEW6)5IZ~ftYLFLYq zh%=1!zFzNgpXT9XpY<@Q4SWS5dX?*m5>5j*jK4G1tkZSA@uUJsLzVVl zzpQf|?=A|!esceM?gVLG>Q5jBuVg|t3Cyz^Wl?E|C?Ntx|K#G!$}8hz2v-Q$7<&8L5e#*rUs3>2FkJMs~CsdxcjTgUE3T zR3-&KG)zBKyvT|S)gz^4`T7~PR-_xIn`1t&g?$%|k4fA3F``$jxe} zDlb#L;2oAaNt5TNYtdSw&-MVLLD8XZ_1Q*>dClC^A6Ih;YkD&g{>0tF-Lm+3`pP0u z7-8z41KlNZ;A@9|;m?SU4NRLb}#8(O$L^r7SUX-{;k<%8vFBant=K0V61Q;b>6qkRal$ zUl9jjiVkKk1fk3&oQbKCe=59;wNpjvV%*hWRhRThPX)R#R>phtok)2Lgd%ibwsGry z_6R?y$>O9l7%IAk2NOYzA}c6)4y zp7+iEb@bbmE}0MAtd6#>_HD8p?=dN(5f`LAZJ<^&YQD$P7_=^Iv(n}H@yLAb^qc)x zBA6^K+WxNzMIX)97tCR1!ImV~CRioPrT%zDp!?uu;3;jz%hl-~`ZVf-Z)nudsvOUn zIJ?B(f+|V_X_8BkA=^Wj2@iB4V;?<~#eMEqQ*SD757#&CvIVN7=Rjz3qagW|RL0ZC zG)7d>QIvv!EsPI+ThHC9msFuxl~o$)WSGqcg3a5J>H8`b-a9^Rj3o{2tQBfrhM&Gr z=_L%OwB~2c`z<9r)@GtQty9s-^DgrXRamFZ*SF_RmQvMkLG9Ib#XaJETc>BE7pN{P zm4^y^*SMdaA33Glp8ey&^9d~jhODWKfSy$S>|&9^p#CL)GnVUMwqBBYce`N_pG&~saQP|)>%Z6jFL+3t~<(A zs5UxERX(_%s$NO%yKWV<=ytmNOahDODJ`Epc7?BH4zW^eFi-?s;wok8+&I0_eubu7 zoiUXnNn^WT{^(x9pQPvDgAdAHwX)`x&uS-7Pqwj9?PdW{!1NUnDF>Dqp);0&m?m>r zsHzF|<(BPbxEowb1C-ceKRuLLDrm~H{P%!@RE(Sb(aY1})I~DhQbt|bs%QOy)>}U$ zgi8GZ?Y=K&#+DQCv!`gNcB{BmVlyZLoy9KE^Q~_RSo?Ku!Pkn0fb86rt--N%WR~;4 zi$cJz0RE`Iwqg4BBI(cB|9`!pza4PuB3Bd>hY%W3+kyb{#+UTW3zZaq2Gv{D6$Q@X zFA6|uzxkc^+`kDp`wndXKR``nBSfYn^q0q9TbLV^hD4>h@^kl?&JN@!XOlM;En?*Sk@-1 zgnJ&ztjY1Y@T(u^foXu29ITlo@(75$>m4R5Qm0#A7q)@ge~2P@jb*<75ksJ*40EGF zPtMaCASx^CrMY524I>U-3$H?bb0mWfKL2`uh+9R*l{tT5`_G+Oq;=;98wL*=#|)qV zw?)fRQ%2~kNP)9cWHN2Utq#ox{KlWZgm!7?%Dv!1lxVV%y;IpJDeV#Te;cph!)_0C z@qs+(l&OVPWT^~m%pK|-fePpZ{k+299I+ML08-DRPHFe(_8BtYXT*oae`0EmTpJD?9E(R1b z&`eM!lbb0v6LZfYQAtS_%EhQo1N{e0v+JDW7K|K1S=GY^TA*gN0a29E6Xbfkbx|_# zeViYlpJ{Q!VgnV5m=;^(snagmytSnrwU$(D{CCyc0Hk}p50rLUl%CglwsrI*H!aPs zru)bd2hf2Giq_@OyV9tZv$y=8y{D-DI3_2obCa^dkWa58@sU{ z6}zzz|KFppC;a*)@$1tKr6z42wvn|)VDIV|JdUZ-`rmq*sJh>X3zEyF7$>?yfc!X& z2%0}vvMaKANW}T~!wLkOkWF+Oh|LXBlK-w`!(vPO8bFhgew`0I%7K6-E9h|d(s9fc z+C@tA3d@@|`du?zN>kG` zGd3nigz~XITpvgw9L7T9n|^2k9Gp~|1$CRV_30u+lPbyH9b0flx#Ml+@pAop`e55i z>!|>#^5EKNVXs^W=$2PN0uu!??uYLfu8V;~@(8F@-)BmBJN{lh2Ga3nUqujIG)D_m zG(m|=@f5uT0$}EC30YO@>&4Si9-DPPXNZ#*ZW_3qF5Un=F{Z?BD67P{?q!KdgWbxU zFRCdK2{hkrK>eoK{o9V*YeJSu{j??RFfIAsGeq`Wr{$b!b7^MZk+4a^^yuX9V% z9^W=OPpYQHPZ~xpNO=5a&yb$7jP=4oVsitdex(=T|F+C}#QB4*v*}Gs*Pq7kbXOz8 znqoNuc9j&bMDk_>e2@~yD4MnlyWV+Sua{JDz~YqwHsctlGs+ubK@DdSbxe^2rO;50 zY~ZFR<(vUk{tKEjxgkg8hoEv80w$0Q47}bSXE$E_>^hr!nR#2i?3xi*0$)w*SKz9d zI?pNdn`yZmUbZNnE@0uf)kUyuc=eMB2c*|$I@34KthD%Db+Xa-}A#_hNu`%f~ z_n9+q)?Nn$39$khAmjP9D!iFnmn|h)T$lmo1DB(HjMIgg2;nnc%~s*zKRE*jLGkjP zbM^!by?+=01sZ+n1%FTk2?GUK4dZzp`@GdPYZG|e!W)J`a0dR#A(J^7Z>ha+tQB^e zdJwoD0tEUi;Kwu*C;}ZjM2*D?7^NXs-{dsF;TpTJO$bYd|E4^5|Nhz^gw@mTwe5AL z@)^xIAXbrWo~4oi=)sVCEJI1&Y#Ae+V?h&MoghK%cT-OPF__`~BO(^Yl?2WI(u0MO zb1?ptpIgLAsa8|zAA^jtcR=&ZF4G1FgziX~lvBv$bEC{tv?tj~g%hE+)N6w+2mm!X z?qgAI0DOXspQO^>pr>Culw!bgu66=No!G^ZQf)2(Yaah3v)kTZ+YEX~e|0mQFx%$Y zK=Pl!SWzcZgu`U-A7miR+(wZBI(`^I`*hK$0>)W5NNxK_;)op}X3ae`*dX2JaG_gT z5{swV{M?u6EdvHG$RAq+B;pTDGyDvJ3|f~4k|ZVL9m@lfM0ab@$Bu^PeV}E1{L4HM zB8#xyZ<;`k&NKR5CtxRTlC7psNo}WvlF(K+tvtVkQJ`=ei@Q5D=klJOHIT|f2jrA3zTkmCz0+c@PHxjtU{5RG@GzZ}`!@t4ZjP-^ ztX>KJVGUve{ULiQPqdIAWdXl2^-%w9Rpz@kV;5oK@%OW2qeu7?r|I$AG{r~HiiG#S z$6ePZ9C$4EPa7P&r!eM4tWt!rxwo$J{kY3ha6Zy~mfBCEpSdqO+s@YSfl2-CKd&s3 zzVMB8Qs(wz;Ay&~r68g&1bavvFcy7F#Gw$-=mltFGS(@8#4qqpH~@hbX3 z*VAk4qIldHw)&^aS699TjV8UHhhG4r+>jjgq>>b{THNHAUE$kB(ZFp+KEF^71)2=+ zZIGjx)TfOqx`t?iyplSMZ(Kgtg=myyV# z$1x>ahfPk1X1YZXoS*u_hO4sYQ8H=IQ3{7ik#jDpHN9W|^O;q`Gu7yS3@=|8uy_n}govxWjbG*O|+!Qd>$@7Pb z*Nw=b3ty;lprXpSAb0R3?Q-i)#^suht@`j&bEOJlffs1ch}>0}ILwnj$tl1d`liROYF>tTXNacrO8U^nQR( zs~#iBI+|jo?BI^oHNk2ZdX4a?v=au#aC5j>^z8^r^yOTr@Lza$-O?UDgFfFawFUnq zInK!*2nEInvAjHI8mO$GS#uRX*9Z>yvF;M5nMEX4GF~0SM|-*>c@1d8Kx!uy{9;@r%v6#mw4TxQfgVWZX^Xd`D%&SeP# zlw1g4{O?KUfkmkpX2V^%7a+hh*6HsVT2484VQZJz6_l7ACj`c+Psw)c6q0i;X^Xja z;nOq^-9V+yc$s29x7XEBh*ZtmVgYX@Z7Vha~H zK^Z7YQ6mB|dm`gy=b6S|oM-te6{z0;V7zQtUj z8E*cWeCk@i;o4+n`jG4I&o3ev?Rf$hfuD;(TI7`-Plk}+wzc%9q7~BC+_aM)rT-}^ z2ovLZL;L@!uf+eaC13zM zanWm`c3{yZdoVwhA>IweDQ%vwdPfJR2=0SQAqdQ&Uc$?ysd`8|GP(Tl2IbwqD$MS; z?!RTAy7(XEzhMXeuY1vxPTLKCa&T>3JVSAWQEUbJV6##r!r6cV6CO=IbE79n&Z@+f zuD(DaEeD*su#6*k=*g?Q+BwdY9F-;R<~mhtL14d>=;SM%AOX%VGhSsP z%qH=ll$X6y=>>(C?36;1jhrd@(Cy75H6}8d<8WLf|Jt0!hb@vuzAEM*AjyUDoC2zk z3ZVTQpPR~4qyY}upBW%B-?Pc0y8ZtDTyn987so#Iu6&uT+b^I}-0{RdJlxe6eP8uz zojOd$9uPWWw3igNN&@~+IzC|OyB(lM_4jjhc=(NgPn;KQ=z73nhdJwoQqlf>BS_0| zO@vaU0_>#DM6i4_4PZz15C5CsK^0P0fGo8r@%FP>dU8;nJY+{A*ly-@;u&UXJ- zNghr1A=Pr&Vs5NntH4f*Z~?Kst~>-Z(G(M?2%tC~fnHSn%3Y1K#OdCl%BmUQd+=Y& zk8&=>Q3$DdLqH^Ee>`4@xG+Oh2KMW=brcL%Zq`y090a^Hl=8=|;X?gOplV-9(bsx( zmCvxTXw`gyabYD;?$pMW$e44-^ z>S*xor>*V$@`UF_uA^#j|5KDmLfz6~YcQY*ri^r*1OrrBU=pgvLYe{@@#{s z2^SAO+WVX3!&i;WUtnv&3PH8= z1>&v?4)4jBbof+5(|`@@hCW;x01&kxS@>ju$2z2cP8=BQ99UodTkQR`-XFYy>CBb( zb^U_Tmc^%ZfezpYbqlC(`Us#UIRtF@3#{9Gr@sNV!F5iGm_0(MM%OV9*lvJui?4kJ zTtk>j!|E(@sKo_5a?))K5Zfx)TJMq$3~2x3f3;H{7}1#Pv_4jBlJQLantw3#a({lP z*nyY@s;6arra2R4j_UDU%bm!wispDj^2D8g zk8H(vAkx4S77i&*2pK^g?+Nm4hkgx`h)1tdX#B2V@K}a!VC|fpuc7)0SmHuc!w@pb zmpt6=^Vd5GbLu*90Tp+3*^d@@D+$8z0m-cLa^t#GIoK zVnHwAAA@B$URJXpi?BqP6*~JX?;g;Gl9E_#C zi2-r|93$wIxDG}j-78s6$8^Mbnxm>3v;EEKSBPMMXqm^aa9+MCSgZeaS{$M=b&7`E zv6(G>ca)2|joFr#>Y{Sg1+s5+ewL`y%39F#{(AdXVI#c-ikb2}yTphkg`Yfbff42d zDKMIkDWG|S^??aDFc1WXAvU+p7Zpaeu4(*cc5>j3%1RWKKd<8|tNvJ#kJ9-olP=$R z`WvB-zyqp0i^4uWbDIjRWWJTweYC*`kewIOvMjn%O?pY7jdcTUc<*_x+*JQ`eYoO3 zImZfUW*&i>Os$;=Oz#kKGa!rqdS;8eaq8$X4|fL>2g^%y9D%6h0uFT2(~U&q*kvI+ zsMLs2)}5R61IIA`Vj}u5pc-uCOwa|*li5?R^Z_0SA`dY&%_3PX%TtIcaA?&iyWF|f z%s?O?wGR{*pSI*~WFGEghv#twOw4rSji!t2RQ2n-1A;B0#T1jcY~Lu89+(k~))TOo z2Z5|1vu0~xvcA&L1GIrFlp07hC4e6SOzbNl-Zaxakouyl%r^%E+bBabsxV;49>q7- zNC0M&scI?5Z_83EkO|Kl!nug9G@lV);DQ6xsenE(z7u7FS14r}s7~(xC6+}V#|sZX z1jk)SC6>8Px)ubbM(HXd0V{5@yaMz2pK+O-$R;lE7m_GaTmKShKK5%@lzCYBETqw3 z9c#&yr>Km{W3sMWW~En6C$_-UbwHg>F)`GqA%9nkCJ^h7zdix;?P#4W1mN*rSW-2MGC6xJn9_s!1zCZuL=lf9z?hxc2pDf5#P4O5jt2XZJIBc zNG+V+?lx4-8-0@+%pHLL%ai%K0gF@1ef{2BE^MZ-ERXLzbY87VV>(M^@Xq!6-#r*IZF1vSng0axCdsiCoHtC=Eb;v2-1I8M4bL|Y3ZyrU`&rB* zpMTg00l}HVZ9CTA%-C?W&@x&ob#35>-UDV{pOy-zyKkp#nzc4PsrAi!waoA`t-8R+ zOVnSmtfgBTC+ZzI`E!0tCQ&!WFg}cX;e9Pan%}gY{yXtAt??0A56;}dP2N|mOODlk zZEQu+V^-Qe`^Gz;i;uV~Uh_dMIC`3M+4>dVZ)NFe z9qPrL)w-ABhtg-n3E$12^5X+J?ynEs3I65+tOf!f95lDze~ z*n{N>bDm9tY95Y--aN%e%v`d)i<`viZZjc1Ab9>^qw4VbcZkaw`liBr(f)MLN8{`x zOMh-g#>M!y-49CzU;EmN-IO};Lc^>sWhiKTtKzoPzAQ#sqXXrn6P)mPEd<3;!(#%a zS{;1&=HBp?HeT6lBz5Ne-8dzu+E-&ce8W2yX6$84EA^`#@2*7gy-qHD_b^IV@zZsb z9@BUuUlUkom2Tm||Gsx9+uo%D&aQJ^MJ`1}!;df$?mKBNA5s&9dbk|u-u|L9!;O%2 zIJT$_?RS1A`s#^?yRV1{PE6iqJx5X~#=->UUu>MrX*8<{aV;BL`#af6uJpts zos8ah>R%B{!Tu*2vWc!~&`10~&u@@9@>n6EtOOE{}RFWwn{B~ zw5zD7C}eo(Tkkil#f=m0#girPgN2jN=W4g!j_jiy(#ZAMAbO73lj|l4cIx$mckYi% zD^I!ef$LfNSJWpC`&96FxSe#R-S(UFGRMx>DymOV_`!iz4BV*q40Mmmo|EW3R=(!x z#9SD1cy(t_O`x#i+ScK-~d4Q#$*qCtZ+natHJZ25BE+=f&H zU)FlBeo!I0VGXK3j_J}vOk20a25(W8MFH&7MM64$`Vt`_%9iPgS6?V4vk#gy_DzcD zUXLACCvg}f%`@#=TUDzSzmJt*>zEmP2oz}U(xO{mlC4t_@bLZl-eRg|@ug?pF>Wc| zL@YV@^*~}8h=z&tEb*Ex`N{M|PgQVOR>Iq?J$pW+wygeDoMT$LduFS)UdA#J*NJ(J zHT$akXDimVvn$n@O3KipUhzjxEpL!8%C@n-ox3m8@urUYw!1I94&Jkc>9Muv>lC|f znf>c5Zz-!csa}^1Pee|o3mi**8Z3vN+Hck+y=mE~``LGO<<{1{GG{v)kT5hwa*`3T{d6I&$J*!Y%fqUhmA=%hJ{Q9RBHS0Yh~M*|ObjQ~PR3 zPD9fHP7leUz`jxL2tMC!u@&}r2F#2H|Kz-X3qW$T;B3ZMQ=>zqp^w{}-RT$^nV#_9 z%=EkY!UU-!1Od?fc%|?T^}*kl@#C<$)kINB+cd|LwQq-q?09v!zaKL#y(x2n!#Ftg z$hlLwqv={fgm0Ydbia#$odJ{#3MlOEzU0}^iqDQ66#DJ;$mlF8+XmC_tt~T$<^>|R zKffrm>*B=f`(|lItgH5K{Sid6$Qnqhh3al@%6QB{Z|aa8-<7Bxkr~ArE063?pTB(6 z*y>ErhfhksEqKYV7mwg){X={4oXMF{*+cq(pfzwi8pD=c!`75(g^1j2yyq;|Em zR?&wJrF`2Y!SRQa!T_%6p>#2IRV#iOVJ*|xyLg{vYTCQYz z@E`n^6KFH~*5R(wCjB*q`!+DbP21iY)@7v`KdjONtn7Gt zt8WM=!j9SwtXizF<5XB%+ty8D|JTd1(wo&#m`xh0-Q~+t_UruD%5Siw=Iic$+Tt!5 z+W!Xn^{I3fl&GVHd+MTiDj#l@N>6&>U!^;luQ{fv$-?@p)b-)W(DFpFjmdUK)xKxy zjNh?1)wL~Obx5G{W!B+M2c~rI?GMtpsgdC6lg;EAwmxx=FDF4ya%f%Bw4(aZrj+*P z8N;V0*-ZH*&Kyf=uatH&EOwzVYg<2Wn*CNIIsL+K*+Gj+b+H<0IX3zV4h`)|w}!g` zO;3s+h+R=*`mC{!WQ4-7&fP1x&feYXrDjv>7`YUh92= zR>vu@AedV?woKdR6NzM*kMU$-pF?X$54`32^xmT`#e_8*jFZz&Z7TnDtvp9Ql(Y7c zyuWH?bJcA%pJ8>{E7?o6ZtO)|$Gkp8PJBbm7-OS%aEg3~{qFSLQSSq^IwBY9@C(&b z(pZ%y)d$!66!R$uELQC)p$WJ*Pl^tG;9jfD$tY=&)WTUHBBh>W(g$F6ZyL<(kTj04rV{!bS-&yVLk*g7~e=XyC{mG2oghx+q z_23VhM;k=9=veUu4_qduJWiPoXUDO7xQX*aK9 zmAl7bh2XQhpROOu%Jum0JYI#{12WT&ir&Do%V#}@cCb7R7`&X)`1VES9oqjMm>&Z? zdJDc=^j=HoqbBC_&9yI)8rc=YWj17;E8kXoqWNd4qhRF5FQ3;*bls0(CfRfhs#|6(!5od6bNi-Wrt7EZ zoefk^&Z3CcRG;YiFe_prdX-gMJd*Q9$<(&AktJ(%UOL=XlGy0;cJB3jNxUKBGfpd( z8U^mtZD^&^#f0svHPucDJyp$AF!Qj!z4DTLcME&bf#kORqy4raDk<_Y&dRE(dG>6k zY^SS^?Fq5h-jQ0jTTOK1Co`L?50|Wz9+gj-tj#%muGe-jD_*!{b6d0ZRYrlAjVvqz zUs@)Gq*F9)I*;wWe}2c_%!5s=qh$`!_xnzDJr6q$|A63yG~>Xyjg!VUp=bIs2Hl>P z{de@XV#C#dl~ocfaOMB4R6pE*Tsz`Y9jP~X#>jK!y)2nx1zol#PUgY-DG={7Hn++P zuIP6@4!C%4kEaPITIjr{ZOCF8ajLa!-nC=>y>LSAI!c8FD>q1&KbHJ1kh67fDt}U; z*OXY~^ThYlHy%MT&>nr*Rl>OCm8bBq)ezd>LVqz9^MJcrZ11X}3QwZT894N`a5h$TM? zQk8A0AL|k1*_2I@ADg*#tCGhL^*ljS~1Md=X`WMWkqK)J^gRKX;r01xw8E@vaIdt zi|JVD<;_le-+5-tCdFR2o^-sw=thwABG(wE-fIE+Hg9%Sae6M>TKXi?%+lx@U!9db zECGdVwC|Hrwc`377xdVR>;3iORXe^N@lVlj;v3(~7b%&(PV>;44etY(IJt^xizB*% zx)LO0>CtS`5&d^UW>syP`RGFm_k z6KSUkdVUWAU>hfrZx1f#Eq>saKc#PT@CcFP+LD|9A=V-1es0Qf$~wJUUskR)AaaOm z?)G^SWH_%9yO>~f<+DWg_A%;GR6Kb2w_W>{-v>oJ-2;TS#_aLT&wcZ=1XA+VBf(;b zX!Q4U^qEEWUtTUU9Vh0#J)Ulyzjc0gs-w&PP1d0B7UyG?1wAP3=ia|pxNNdJZGVFE zN@T&zi+)s5Tg`11jg|U((*<#v;S2S19tD?eR~Qtn|4R@FLZZLWRvZ^*Lxu~bsO@^O1x#sMac>o1BtpT>K)FPU;AN|o~cbtWFRf*TF* zg!*Pfdc)Ff426?VY1-ynNL%SR&O5Hmoq1E+#57HFb^w_MyQa@_ccs5e5I*XQ^p?jT zo?q&kF4*{871inJ=v0`UY2O>ESRQVh!GY_ZwzXMVN6WfhZ;7h9hrZmpvvKe1BrgA* z|ErK!>qKhL!;eRXXc!zI6fk5yZ$ z{xZYD?VQ#tlfqJ&QKHL7nv;}KS! zGu)X}{^6ob@>Es_H&0NUu?bNfsogG(3tuX2l;?LQuB+qCb`w{9JGgZo1JC;X`BK`u zu!fw$mJ;zco>-ri4QsR*M7KOHS4(e~`@-|vy`{0@X4+jqj&sw{N4Zq^sTYe#h3$o$u(vROcD=cxy({M*g_)O&0UW$Sgrqt5t>vApb781C~08ufT&2zhM2 z>ffsFYh+Ge?(I7yxv_>++vdquRxzC8aj?!*H3Xc(-0Gf#D@uiQ9`e$i)wTBUt2`PDU|7t*^gKJDPQ_{d4lEwlE!Q`?*s zBkCmoQz}i^a^#-Yp;;39GdjOFWQ}9ujjyJA-4gOA4jUzI zpkqT*2P?8!En4sH-)}sh9eYwy!Dz!H)tJoP$DSJ9V=5kc5-41;D_OuImDAz&1SHRC z--k+#{?Os{6jKi;;?A$TSdgGPY#)ryp z{_fy{FYAFaREW;LPUjnv%XRljJ(Ice1=2Yz9eR4$*{V_x?X_z6%;8&aM$Oc%@NC$gplw*LZp6 zvoC;;UrwMqUdf^esaep?{d@uJlWT6i;)xV4HEZb2&mRl@LPD86qKgUE^p*OlaFFCn z&I;R!*!OD;Dd)udvWUvb_n#DBFZ0M;FDp0ML{H$mfeVA^$ba^3(U%4KV1q8aJCqpS zeP7R@iObi;J~}$ir|ynip*lpH$jttkcbJ8hUVj#FNWGxVt>Xx@yZKeaino~6tyZ~a zgL|;)8mcWjO>x=IuZh-0_GRhsl^G~k`9vu6t=giG&d^!K1LO{oFk!WktjYDIhI4>c zPsFu&z{wUvJVN(2X|qKO3HB35#;-fn+E3p>Fg)BYxn(aCg34nY2vB^)8vWasmh{zHJ)aLA?MS8!)S22#e z8< zTnzw4c4REvDK&{RTA;{#+89TXjh5jag&@P?B#-?Wu(kv=f zemq;UkQ_Y@ljN4Q-xj_G?a-Zb1GMXFb?ad=TPAyeb+MS^Ux_V5jsx&8PL$NLJ~5!v z)Y)!4Oymy)y=e94|LAxZ^FUD9y!Cq}6N9`JG#ee9JeOB)>z&ixw@)5UH}2_TVus9> z(T<4es{~!Mx;Hm1ggiaU&NW1In+L9O+L&z`LPefwWkqRsIpNF572MqxesIY%?N`FWLb6yjH{93P4mBrB~;vl$K%K?c;H-I`WhAuR0h)o~K-t*(&9$#23=xZo7 z>@iHIxOfCM98vi2vCL*{^4q(FjLF~7Q^~EDv3>Wryd2@VUaQZr*I9A#HtgDc_z*95 z)RLW-tXS5sqq(h8=4JacrgbyoJvTeaOh53=014wd`LTG+_s9{UceIT(S1J$Yh&BAM*0~jMAg*$7T(~+Y+XLNOYbUBeE{kdt_-u9n(g59a0|b>fT9zoAhe zP8-3L-v72>)igU#KfN|mChpS463KPF3oq-0Jt+Ocjy|NnF*ndwusfhudjy^WsZ}*8f1Mq)i>;E(LsGJ*KI}XV&i^zx#W*>u zTIL)4%xqi{Mg9MC;sd`8)bqrWaNmjv2)R5|kV+?CTz={5uGGKbuUmw6@rtIb-7E1i zP$sFvn_bb2taB`hSn>C6ssBl?u|Q2<7(|O<)Pw&)2aaxC)KI$65A&;r)p-5;yl zRgf=qDvAEOcjVzc(f<++Q}=SjDTy!p`>nJYYWY_vIYv7vf5G%_b02=wKoZ?AYyOX0 zg~q9$LBmZpki=eUB8u`fXclhpR{lG*!mAnRy)Kl}OwJBnh1ulv1-tf~cyi{#lQiy^ zAxT{1=~2TY7u!lc!7dXA!jie$80^;RTB*CdD`=|!zOb0!e-_YDTN4XYB0VPoz6D6Q z`x*azlbb{)E&87YkY$D<7pQMVKtx(|QLg3%k+(zJ_*F*Ms#ZYW^XsuVl|Cnpw=mBW z?auC1G^2lSXJZPj5-hMY5i`TZFY0OzZnWWkwU|SQ6m#+}S8^SS8^Gzm`5AlIELr|O(;6#i@*N2+ zja5pt>u4h6-WP6*_q+Z3Dtqh-{IL+n0CkClF71ztabYEK4$U*(r~SQ&YxPj_IqF-0 z#V@R;p-prQrZ4wrc*g(lr6n7%8*P+z?pA)VK-F^c6CP4vliAj!){wGGedHD?;{K>{ zVp~$Q?h5~x2$)0oPnBUv@8GoIvhVySF$m)O2bRPj~zOIM$Z6|e~*#>j#jvs zHRK6v%^#mj!+{NjLDUT!!oD%7T={!@{gsl;V9w>x6tgS;-h{Yz$jcAtr0$K>L3-$K zu?X3Jw{eB&lu&q_lxMyn)r=Dk=+j`>>t?i`{CgM5VVbh12Xu(q>-6vM$9=kPNF5-W zo*I7ItABU8nJ5R;IIGb5GCAiSxO-)E6GdG3@1x*d!^xT+WPw$bq3EWJapSL{*L7|8 z4qoyKyMq%a$bb2RWLf5CG@V@Ox>V8vU8m(Vmn zyOCyM16az$byJ%EX3w&U-UnzJD@7{$v#Usd3o zSMpU31%Cfv8A4u+aeDn5y}uK%1hFu)5~CG91L88Nr&~2|@BVj*yZE;>hGD$p3WrK!NKb!}2BtGLkLYk75IDX0gV zXHWJs9ueif3U$nS59R>$1?$|W=$3zHEA95iRVz?FbYMrQ937HRG+@WxbTiM6>Ds@$ z^2!gg0Muqp(ie(uN*X*-5^-!!WJuwqi2Z%^{VGUL@T6JLB$$U}n(v>~I*_iP6r?8{ zary7kRdX8hXBSd~lX?=Uklj9Z1a2gJdC7R~-$MSV6Yr`XYw>7a5ZMtNX%CvV?&9-6?ob{a3OiqWN#LOc8KPE?7D0kcOAxYMus3Y6kl zMIY?kFBBp3?;T{41y2@Fg5Cw)m^1OSXyH{QffxFc||YTndZhpG{+A1lon-Roy49|BlGu5YabDE>Vs>8vpAdh zWq`@0q06|eNqNTp_XX|QXTktWMB#SENM5X6uiU?O$ILO(F8{kvqjDA!!ZL5(4nfFC zw5B#>v_S@Tqj`|&r}W?3<#)zLZw=yp!GQs34SR7eD71mHvBs1jO6Zz@zxCqt*dMJ` z`g9smI)SlU{#`jzfD*d$-;XtYnH4yzP#ZEjo1@Pt>4Sdu@BT>D0{Pq&#aoZ@WzD0E z?NbpJMMc1ApM)9QR{Z@UDw_Uu{3M2SEeqRYJ5s+Fd@)o?#Ms}b5n76g7mE=Y3q^aH zgVI~GemOlWKr^S(1n{j>{U6Ks=XM1o^f({B7n0lCz z&+7uMteyo$oB!|T8=3))S{$BbEEZeVZZm9K!UB@UIU=V;wB`N|jAzd{PF-3)V1d9aiBJfna{e zjVni?^v+M=?XSBT{%FzvZ1R?!*!E^5m`MQ6J2S|`P80i-b>N?|>*-wm%l_WTPF*WT zlE^E##rakTHu*~5YLU~I2O-pvLY&EIff}oS53G~)w9%VS)?;GK6dlBp5yfkic_>VU zqu;Rq4|B`juP6ozWy%lh-+K)!mkSZH9mv9Jbds#_gz0~PmL$52)Q1rVAi=eKz|=iX zRz!rhMORWH>i9%{C+yew^9&2eiyN5MId9#mxdrJ!AsbPhNbZxLd^bN=G}!&wN568l&VC`)GEeOBdc*M=G)IX}{rTR21QQ1K~4y@1*-}Eji=hy@=vPsbV;} zAfP4w*ZH$q$rYEDZIB}&0)s6nAsvrS+oV~K0Dy2J7AYTM{v1GjP2oe6>+AL_kuzG# zeNibA5t{W`(sGH%6X_yJy%+#&x_>#fnVlIGx&pC|y#2)xntfw2PZ@5G*k}<1EMX6= zd||6ET2xM_$G_r(g|VdNp0`0xQ*J?_ggg{??H~Bc3hNg0w#N^*I1R@qv`x z+{B&~2gPh_?>x}axQ+)lTH_@+%0(7S&xDPHfMmquZy%W!Z|z$-c2MRhK8bqO{dpHl*Kdo?!`3t5ZM|<9u1Y_AllL4fXG$q&pcb9$dRXBNB}Xhu_znSh7Grq& zk4vfFNK!kQtNjJ|puo?~LQ%WIIBn{qj}v8Kx>=Fs%WHGP?}d1ri8J#KT6vRb;)114 zOE7mf#Y4G8OQSO~NQXGV#W259kY2VSHIV6Jb$F5A>*I=s;zlyNvK4M(@d*J2xLq9U zha!Qe+2#qqD_Sf2TLsu_-n@5lBmU^ijjw;!jaWb*#PO#DZmH^!wWtudOl+aXt2eQpYa$BtO75?-upQ zdKnc5lz|>+zY@_DCiLnaj?BKq?fI}^xar|;e|`=>pUCP$ON63YP{S<41UN>%I;R5T z6WuL6mc}L0=EMmCfmhb33Vc5B5WZIOBNJU-ah52%+diTkjPNdWdFMQ-{?Ht$zjpl$ zxt82qlo}I!kMFy67BG_Vy0gmvk#JJ|4|9EgMvYr_X4&1!l`;J3QlNHyf;R?H`NIDdB7WA$&nmQ7>wPq}I zS3_0h#K=1VOpBIpEEu0p3TE31CMrh3?%Mp17Hf(0{o8l%cq(K${ zW*bfc&{RZ}TmaF(85>>OurP~8tg(JfQ7}Dh?myX!xO3tEBKK{4Bz~{m2_0y~`%AA% zo)EL?sJYb|_n|ha)>h(AD;7J_TS>#48;R(|x+gA6p-N+2AReZA&;m}m6$r$$#*1W_ zoF@(|1Lj}KyG-MW6zJ+A_eVqbG8u1Al60ShGp;a>MFTfr?yf-i+rm;sOk8t*2;VoH z20BGw-+E!KRW2H-P9EcQUwu+}12qH-6 zM!KM9e+}O}ZwR_HuHyYqpDuty*rdpGl!qgvK+4oxbbMK~nrBW$Kh1;{I((xxbK=}F zyct~iW-e9FZ7JLUex-5-O^_85#kL#^#d1$A8=KK^ZeJyG)9iPL30Z*)Ik`#i-DBSd zv^ct8kmMClCir|+@#zCWNK>=CY+vbq3i3x=oTX&3(m3nz{VZP}e2TW9y*G||w0(fH zosLd)rf7xJr?DzWqv(y8{xtUI+~b}5cjoJ#e1bl=2#O0@oxc}k0kDLj2qV6WCpWv7k;1!gp|YTh-$ysCCjAD?C6hDhNQo~#!@T0L zpQJM-RAQmlqg{jeY>R9J!0V%$iC1&mnuRDI{_DdBQzFE1QrHRM#bU~?l}h&yacqab zgkRl+Vo3Afsc^_(r#nHP~A?u;_A-74?()6(xH-56~>zmgNh(6~F0iIaURaBU)H z<6>u2z87zJh=zX7$-)8yq>!%;1>kIW%Q!ja$&6s2q~JJ>>^wtHGSrV*0h$5GVAWWQ zdPoQDs4jpDesw#F;j!)jri($nps+n04=v|SH_p9H9CsKSVRjw)sT)MoQQ+5nc0s5B z3ARJKSXm#dSeLW&b5jCQ<(`}y&-IHzRnyBVisPKR&-#-6fV!5L9JE#)Mxj_a3N`#@ zw83a}sNY!FpoDTEc^=USBIro0#1W8INb}-64HMAYk__8O$Y{-oU5wOJK3yO<8ju%Z zLjZeH;FFxju}+8F7|`_3HAoVIb|F9Gc!%N3tS$0~$_ru1aq@A<2wFzr-;l6LxoKOR z(dxvSbUCK4Wn+4+x#ku0wKDhZ?bG`L^V!G@GdCAC{V{*zIGQnTpIM@n#%^FUK^4FG zX2r%!Y!)Wlt-%o0?d{tmZ&iUDzRebewkvg0c#YEa{aJ#~a7K8_vytM6l}E&un536% zve!;`WHiM}v3#uU!2ai><+w+By`kMG>Rm?XR&C~=YC)tMH=;X=3;%ht_DVYmVqf=cw(WBDAouvq9vz2(hK<%B?6C>Zm4jh2& zf@1k{b|t-zdN|L5;q($(&eEcKe-i8mOPTsaiSP!4t8HkOV$dFTsjiO{aD4Z;hv!?e zZ6+hT(4s>|=bKTDkHO6m3t_gZ*>w(Fdgm?%isCz-#^Bc)IlEWczPiJ4njhD_*+*ZU zf$FFzJJ--65%~N7vGG0?UIxlzWbB3tp)O-cYq0)EK@9;fk0FbOK!~I?zfygNv;ll} zeuqpLq|sFn#a{I+c(D1&=K$47%@-qQbx{T9Kgk`li^6E0 z5Z0f4gED-?Tu_FV2fXd@era5gw*`;9fgCmplHxPHk}kw+hO){-99SUbXFxO?8~=C!lqLHKT5>S zXJxCB_LbfPm!V^pizJ7MZ*W~Lx!z9j{W1b+`B*jpA0o=qV;&t=X%ou;kfdZr0(Xw*FH82*m(>|*|b<;aX6co11qTv}zj$dPz!cqugL3w8Rz znxdo!xaK7gK;TrFHTu`L-+aVL$q;v5=-;V=``F3RdSt50vx#tPo&DRk3SAMV7;-QC zO1~E;GdV-AFvtR19>b&}&XH9TT@Ne8leEPY{;)oBA3w_~=I6T3!R^IRgdOwc!ZE1X zM--2@vQWGgpIJ!gb^;r#uu(3s^Kc2uiND!I=15S( zVH^XPvCyNVL#^}$vuKsWK)bPm>_U`B*Lda*YUZKbp?8raR;^WJ=Lhyfar=wP)|`=G z#!KHqs#>rk6YtDG7O8Ri@8u{fM|JrKdHF*3(W||NDCa3b0~K6)yHOh|wOl8Ak$AH2 zbLhboOgXlbeF(5M*;L6s7ZLYdQ8`f|mnkVySe)up574-tcp?GYhr3VO zt?vY9)=?mfn@vRPHt`{ViEPu;7lBt>3{YtKENo<`kP=j$IFFoAlySvuNPKTO@wG?T z?e|mS1{48Kp;YM#g0cD}+0P>`0q-1D+L{PgS3CZMc?>*3XOgYL)?`K&`;8d&^tL=e+og-sXLqo$ z+yZhHgN3Sd0+pb+oogRBx_p0z?8?ZWXS$qz5F2L|$0~YdsfT8P^tT)0U!h3YH%f~55 zRJ$tdS>+M_ItCMP+9IwRY}a3#sOiA=9)W6v$#eUEuqX~+*QCwQ$y$bFEoi6)UyQ?OnLPn-h$bd#hY&~p7< z6%J*ST8JQ=(K<4?2)_%a3RtySmNK#v62K6sYdp$Yx)FbCUnNlc`RQa9&Y<-Y-_wFs z2%HB68~gNIqITo|4?A5P0Qwhh3y+Li%hT-IzG&HcS>QZB-{s6|hiMW5!<{>uf%0QX zt749@S0dUY#M)hD-#K_3^;4be^KW~3u#l~MV26QBI|1l(HB}Z+3{z{0)?&~K89{o> zktGn#%63ejX7gtavQIA0d!v|@chpI7muKNeYqlgsiv02F!T$aDI-`=toz(|8{C7*h zJ=?jYI{mgI1Qm1hrJmo!ekk76jT@*i121z}ZzCRu8$F+?t+)hQ zMEHBpv>6N(wCX*WjzC>N6BEj&H87cS(1Q~>tk5mZ4TJ2FM>LM(vvv`Zs*zdm-x#k} zz3?b_Z8$k?wTDP*g;FX{njzVJBB}$Jq-B~VS%g9+NE3u{Z&gx~4;^Kd*q329Btpa@ zDK^BnoLIwn>{6<4pZ##*1}QfB`PDqXDnQ*UOHNWk4eU3w!^8(19z{Gnx`<99lN%tR zvLz#yaynQ1AJ9O98C2VugWa=SWjZy{`EOZUX7;-3m zOQxcVRO=X~=h);@Ud4204Ecr`l;IZ0JfKAM_0c^)HbYC2E|z(X(Ug2AwPL4~rA#y0%X}zTX024>@ zl@Jdk;F=J?uWSMbvd5nU>8P6tUk5qbF%U>HL9iWO7#GJnL=`6e zfYz$F;F)L5+nbQkz{o&}2zjv;j|WMX6z zGTFO^murPAy%K-Ad2^G<7f@(Xuru-*x*ut9R2WJ6GEYz+(#32=Let;T#7uyf4Z`eS zWyrDit(nG~)J7E0rl1J2fHJ7#d>Eky$-oEepr#Z@Yk#;|6+X-pa$&dwZ5QluN+&Bb zcV6HxbSywa^J@k4bL*wFWQbdLG-w*G`QI)+(BSc_>f#T)OfjLE_Og>QoG$0uDiYz4 z^XP@2FKQc(cvM03GEzkliq9HAC<9JV=)*$08G444B4nIIM^B1`J|se=*0%~T$P$vj zn>&%TanR*h66J9q^WAulBb6tS>iA-Wg)VU8R~4uU&S>Z)qDi247@0G2F1Vo5QNm!& zbrBj`YaKQcp$7u!CH=d3-+M^83`L>1p??|eDQ&mrB!O3ha?EW95>Zmr>rrSw+1RXs zz4ENoaNM^D5NF(%mly>#OWT@FB$&zBp*4@&G$L!2u#h-%F5h^o*^Vq8cNX&yi_sjb zN@pk;YjTl1=|QwqX0uxUFNB|hL-7GG27dq(7IZnZ@}+{v3k!2iz{=#_fF(CHz?%br zne-}*i#Tj(7b;rhDU>~eBKj^2yB;a7u0nrcL*nKhxP98|lklSbj3DV`L$eT5Z%wFE ztm9<6BNB@=fQ_Jd%5-0L(>ud z`5Vs!q@ph8h}WRdF0!(@({kMvXB##u)_DPw(!nb`Dd$99`hT%p#5qi8DN;HiU~669 z9Q429j-4p4_8+U|loMVdsh>y>^N`6m1s~Lk1Ch=y+F`N^NShTDcwn+!_}=ryb{ zBqo$t*r7_>9fjxSg=JC{zd?UXL>q$Y-UXNePL-ns23n(r={hXTPZpg50L*bx2hyS+ zo&L;fX?yRb1-`%28w#3MN`7D;jeTzmauMRd3nn{#xo!$DOo2rG{DG{n9y%W)k{EHw zJpFw`QMcxPJbvD5j11rflmMGg-sDvtl6OTR&Z@AQnL^DxaodlG0Is*XSBfseAAjsp**1mFZhQNfCVF7l4)GU^8-d`$$9YsLnEYLY}mfAXL zKqvb*K!l|LU&s!Sx9YJ3PH2Tw9@`tepPNSn9)f`nmo_O?%ud8zC?Y8Yqy$32EZ3EL z1|rig?oqI5GlSka@0*}MJdW7@ZEcIQZh@(vW4B zhM_iq8c`0+s&}E;7`iki5`xeU(Akby3)cKXdZiL#Q!29Gx9V>vR`@hC;_fZ5qyI#4 z+{ML^IuB&`EordGLf~Ksbvi8G2F z07zvKT9D7JX&K2=PYyFEowpBEBVnr`%IVeJ5Qmct)d?G!^j}mVZ*?pEFS$bO|Je() z<(h9BLx~Va%yN*eMjtll2oQev`yU7s-r=yur6qsUP#{BI7X8fL6C^nEG7QVkxd+{% zIp5CFKQU|j2M66W^UhHJ;tL$jsr+$c<#6AT<*&ywxoCW@qBmqVyG?ksocwh*zNG>+!h;^!@5uNYbGd~L|Ejw`gMo>^Mm54i8qigx zaPp7_aqiVcVdvsij%>%Rc<-d9{yD(PK%oukUq3M}ZfKH{&q{!;g?OYiofwev6zcP5 zP6do-8by&n>-3D6VWLrjJl*~LU{UURifQtvYr^hpXYOU@*~UQOs> ze~Ach5j@DkeBi{`u|I^sc5^6mmM?Uq)@&JEVj|cc$V7 zsDEt5*Tmmq?_K`tWhg;E`L1IbKV8M3#r9;uqf!Il#_8FyRHjbtl2zX5f2M(&gobD0ET?U+a_tT^ua0d$XhCZto4S)9v$1`mA)}&^k=-*W zimwk2POyr|CI5+wr!oIj+M;t;CYJ%puc{InZ9Dc%ljViSc5C|eC8``rb32CWZb%QG zEZkM3FQ zll3@IW?KFnHa^d;3L*jBu?EMrv=B#kA=CZFW$8bfNy15pwh;h)HtCAcsNGcJGgz}D zbnlALwpAh}yVii}vQT-cs<`#g*!H`qvLMci)LKHM>Px7P?zp>B<#$MXGcmSYUf&lh zDb3rAlf&ih+tTT`SxZJqj;82^gQwG5ctL@R#o6(O8TFzhr52IEChkrLZOeE`eZlS4 zAs(z0>TN^s%sa=I1~&Z)`KZfu+4}4%?xHWYq0jUicwX;+A9d9EN5$h{Icx@xZtC%@ zN9xDKQua4M@>;QM9NkT`z(aB5yyNXkW1GTO@Zck-7IDf<8w-z-%|>x=pr;M>7Ph2R zA8K?zLk(qDl4J(FuOsFM z7R%sUdx!$J1M3AWg&ZlT(ECq4U?l8MUO4#m_8D&0gMsXTof7u)Be~9jsNjE3^4)>3 zl4yBJc4%4o?eYDemh4MfDy;SN|cMU zMVUYHcCRi0J(e=0%M1);&tPy+Bhk*Id~h=Hm*cp7e#)*Dz3KxZx9g8c&(L>erSp96 z;SK3DVV@vKLyY_a!tuNtf~N^z0)ogdb)SY_YV;#P-3gsAP-BC=yu(2?ou6@w<$ak& zBS76MwRd5+RZ@vk$%D?{=cR2;r3LZ#HKD>d(4CfzL9C=q0!gRmi9UtZe-YQO(XRH} z66pY0(#(u)_(;1#25i?eLN^WIMQus&orvM)N)Nm*$4bJ7iqD~7YSHcX7T02?WBdkwaSzJQ!zewkvJ9+-}0kvKn*hc&^MEO?7U%{;|Gns5I-T zJD=qT7#-E)5mmJ$L@{JuD_y4{kQkBF%0BY&#GNF^ntgSgA3{Dc*Qo3g^<07F$JD5e)Irzvd68IFM!OudS%Rekw&Ovu@e@=J89kfVwcb=zvlt2qQ&2P-N3Ud(}L) zdIv%}jX|7~Dm;(})nghF!=V5pu^R(qFCe)rgD>&5yedf@0MEbzSf9&gv)Fc9Ach@9 zC0d1Q|NAsBjW__1UGqRgJm!6g6PZxajz;goY!gTP@vPnwIO59YtV%QnGzEA56ENCZ zqqUouCpanAqqTjTt2*#eckYSQi4d_E(cB^IxX+w>5hO)J74ryX{%-ya#~YGnp&r$+ z!^+H6!AN8bz7-2tB;pc5s7PJLwNj&d<%$Tz4qAF^TY+@mb;XLOY-%Xe8o!~WxqvAm z@}}t>$64UhN@}#pc(~;WsXj3=oKxBesd6{K;%Dbjlv+bVKaiesr8Z*^9_u66WVGr@ zWQWFcl=m}5cT-b4_%L1=Vz5bwE1&Lze(|B->wSGyY%94|tk#xbx#G=twg={kIqo@V z)KA+3d!ud4M-Jc^euaePLuv<_4@)j-1Y$hLj zfNr#Gm2+|AL4DBV?vt4Xv?h|8b%g+nHKI2z6rcXAH$T5%is&j-B+`QeD3>kQ%jh#{ z{!n7Q25r-Hi1Y|x@0v8tjd}XO`b1(5;iJ{Q^aluu$w(6P;HYLZG;ioHDB=#TiC$v^ zg*(2Zsl1tEkhJ7pKP`;i)aE@@SpAhZ&<|J^z_K8ZKpFQnJS4O07^eLIY1dH+?t|6~ zX!xhYCRMP<*$|z#JPTQZ)pU}CxE=#YRT|2*t*GTJBNi}_0X%+o{;}W|=xxbq&xk5$ znj2!9qL?6}NIt8Ro{t4OBB;tApmj8HiSTT`b1LM_4;%nhI@1nX+dij!;)t5qeaW+~ z*vh&n7^d2y*aeD6f7pQ&66Te{wDl2q)|Cecpg%vKQIQMfe}dy@i&UnPOUJwiQwMOd zMQ{0{xA{Gu-&`_}va=zBkW+i~H4|rpv4@FMWfPaMbl$W$tclIbZh#I3wQBb~kzoOM zwLZXfa4%|^B6@Ws)OtiRib(ugrDdFtNh{aG&lpdQCY!tDuG-ZoxE>C+Wn4#V=&E^_ z55R|{Rv?oe!BZt}k=Du#(`@K)k#oHI1rm2p??T(;tR>z|MsY+)0NxPAOnS`>0Q;Gz z!lnEHz@OrXXT}Oz)dQYMolW|nz7~Ivc-3XH|`|YpoxXR>UUj;_v z!n`Tjy9i)~4qgDo3k-&J;LsXbKpYX1P4F3=LnS)^Kj{7K0f3SGSHW{pbCSTH0E4gR zoTK>$V*)fZ2jN@3ZsD{Q90qU*zXN1tETF|w0;4q zQYuC7nvcbstu>r>rQpQzzI5i8Qwx#n+q2ejP$DjJb3DBM9m6jcO2|%8 z4sHjMG5fo9F@#XIp6lHw-V=RsQK=|*yw?h4R|`E|F!Jo%yR57z_5uND-`FgtvT@RZ zQLb71pQva5a@QxGIUb=NPBbhy;xDij*ZSM^ z+O|?#f*I(_zAx^p#=04N5rvfxKw+#bmx)s2^FiZC6;XY|&^^M=a`m#P+M6goE+K`Q zE7(UV{Fy-Dh-#rQh_rMBnOiFxQMc5-tX`v+@qIeVj)Il)Fkjm=T}Vw{%$!+4JVV-fQ|i^ zvbq?Sa?rbZfVV(Oetv7f(bl7UcI2b>ueVP_7RQm)1hN^S1zEhnI#^upfTZ;g?$0qQ zA^&#()R-ulZkKPI)ZX~#=jSaFjTLn_N~|KJ+U~Xc#R+(S+W(+v_`JGaI2KUfQ-dn5 zsG{RY$mhHtdJkeCUH(Ib97ZJiybnvJ5;=uKbeqYV6(l~{y1(XX;R`;HUO6K!AWp1F z459%$p?L{;bfDtAuk~q|0O=e59<;Ji7V_*7*Z+hfX$@=)lnjZ4P4aigQxch`7i}tQ zR>~qMfM||B+gwbNV<0)uc48%O`IT3quh}_T@chFzH1S5-kTGu(5<$*_n*TYi3CHkH zR;P)j6#b}5kcLcOPw(J>PiA#pZj;-!(jZ}9>bl($7A6dJ91+#>Y{-h1nkItkiOV~| z5J0bj%6JQj;uV$Nqs|UAfOXjunn)AJmCY`K~-V-ZxKaSnt^iX zcJ#8VWJ=X1Y`klQjU_?uC~Q8==nIHs^*e`NYpy6ciGi$)MER38tLi{%~{#dc-9VG@vC5WP;UY zJfX)A(I@y_a=Ov{6zrD5*V}=}Z&G&u5`mL+?g^^8r<*{aN)zC~q*Df+7

q=ttcb)}Ie)@NWInHWe?-zlGUt??o`L}37=+vMUfc8H|Nab-jx zINJrl0O~*Ab{sQxR1YtcSLW2a2kA&!@cR8eWYmgqT*Oae{gXsHqnMT_+45*d9{AHS z@w)`TNE$}UjqDB=b1J${dL`&;6fnIf-{m^0lD3EX#_d_xYSbBoqi;025St%sS`;qhP8 z@vKY{x8B>f6ib3BD_Zq44}6sjJugxI=?&Puh$=`S|G!e#ZQ)>J#sDfIe+f>O_=#$? zW?q0c!e^71DEuYK1$Xek7JN9Meg}{<5SGy5YsnvMFf7GufJov2b{xRdr6mnU14?*6 znSMKr*WfHtME`3-Y%{^r@XuGEek4+yR;5?J6OWpggp&cP*958e#5A^CVwRqf-tEuE zzY|j=d>avyLybBM2`8t6da56@9964LOqmUMNm0WX5E+tyNxT7SYD>eWG)?4+ATy7k*Fx(Q>)^f_8jjFn+|l3oZCxQd%<8`)!WlDbI+7z)3Qs`T+S9^i=xAzx%cByQ zJu%G(gcZ59V?}p$bOH~0W0%whn9rTPQ#3s`FWjj&6CI68_WV7IHaPY~IcPGkw zNRo$S`bhsG4#um~pT>zuL>(C33L7Nu#ay%dRs^x10;U=BM&!UfO=?=`ZxIjP3Tuh| zk7P^zG)cjzke6vaq+Dx0+i@-*BU~g>GI55Yvgq$=)5z&}C7gF?+OyW!&K;dC0d9;r zjBs=_yABrZ`=a?U8zjFicvthU@`{>EoafstnxeEr z^x>-PHlcI?FJu9J+E*Oq^eP*Ut|hY@PGoxgYHdF&m#TmQ6#?mtJF$`W+f=E2Ic$wT zQDBWOivMXNLV$Mt>wTSx@Bi&M0x|>1mq{czTke19tquX)b6U3Lhm4p}`_V zn^AyrIoF4T;LS_yxqH&l0$htB2dv&DnyJ|9htk}Z(dJCbtE2<|eGS>|x6X(?*?0g| zA#buCMvl-Ti8Rj#?cOuC zy$Z<=8&HBzS9^^BWDEvL6^rv);X|MWZ|QX^jolXo=&HdF#p+jnE8~G~`}BVLzYS;uaK!s+7CiMJrCv zW2WzOyfCuBoYaP9WZmXCz-1eQLQi^;^#5Dul^!40qEih|Vaw$M6_3DZUM zc{<-7amh!|n4lEy?g4dP8&6uMFF^1q15bleg5Pjz61qMOa@7lnqrir?@i zZN9Tde0s8+&Wbi|nK)Zd>dix>3|6EeWWz~KMH4S!MvTS(xbwez)m&Bow;02 z2-RjP5sU@r34aSxQN~mqE8X$mBz^=uAru5Je~98xw5gH+27A)XI9@jhWuPyKw?W4% zD~tZq+^$1?9B|w?7Y;tP>AfrWRl7nC>G_bu8a8&WX${{_FN1=nO`pOo-p8PkgQ18Ft7nQ&kOCc& z_&O{t8bI4`!20|*9UfGw#2mem+S>6F zfH|si`J{00)2kY=RF9sI&8OjF3(k^0;TZ-7%p$fl7>Y!L8xey_=P&uC$&PFccADPu z^lO}yN$W+B*5W|_Z$|L}glUVO4=}_H8A7b@N!=~m!ark;O;zDlL6o=7s!=63S)y+mDytm~v?nw{KvQ|ncgrk`+`LBTJZB0SEIl|Ak zuW2w663Q6LVhtd)A;-BuJx9P?o8f}Nl9);M8r!UKa%OhMXZbV9kO@>fMErchKm2iv ze_f{h_;?5braIm3hx(h_%B}{s!H|Tjz4E5GqxY}Y*Lsxa zA}}XTKOS~~9ws~Y6rw$@%q%h@VAq*OSr~ah{FfQ}-^*%HQYk}$vK%K1e1!f_+Y;O_ zdphRn+q-z1M?u`4^RqyQ2V0|V`Ud>%2p{WjWFryGM)C_%J`$HuX4jo<3CpL`#|7yU zE|n9XEw(N;5G+iAUNDiW#-M!g#;D+mHkiCIB*K#4#cz?MKBMw;47AuBRc?dCcp`wlC=|OqBisU| z^9(CaZUK>>&Bh=@RI?0A=+CrIhEo)rUdMJ^#pKSmS*U8ls!NCVq;N|tHl_eFVlbXi zwA5@FBIAJ@9AhaKSOSdji1{Ug4Cl9`YlFE%`5AFP8|)pko+(z`Y$gYoLR`cU3c^nf zRr(`TCCv#joH86m9%m(ww!o~jgbY2HyJ*Qet&lW<2!@}t=SzlMOFnI0%s@q)O^kp| zG%uVU!3vnRuXG$5P%5E=7!v<|8M1@KZrvW-g7J8E%HltP2OOeUJkm>Kl6Hh%s6;1C z&-sLoj3VkR!}MXd*CKmu5YlW^@N)z1kYCo}rNHwc%z3MOQ?v2!!N-R;n`s+B_|;DD z`VDjOYG~7PlTQ-m}8C zsHD%*w_(rYc|I%RQ**BFUu1iCNf5jjIXOEwhYPT2Umh*UFV1uCU#b)lb>#AP#hb(3 z%4G_}O;PH@H?pG!)jK9C5S1Lpu3-U9^9xFX8Ie3R_rx~@A~~uPV<+HMMng;-Um7o_tg4c0QTlSG)W z&^CZF;ldJJq_cviNU>lLtOsJCaJi0*eN3MG<+khTNh#op0M^;Xc}s>@`4rK_{VHp5 zP*-SI!H(nty&WBBH5h{NS%rkbDzNtpvP&&O^Yk<_iiyjlT}E1-HPdvYJ>ZO0P{;%} z+ankRDw0bs{%X)g&&kk-uznDyR}2>Jb@+r-gKl(THhsT5zQ1+f(W8l^RHcP1s?uU$ z(1HLsbCheoHZBSH0AM8G+1*XSI;u&pf)^}UKGuZ3IUGGJ4jbT9WOsG26hyuk7 z0P9_D8VwYYTNsG!uTM79x0sNoIW!@>5NCX3H0ZUMHYNw*Ty5#)V%vq8ChX;$Z~Ka!j~uC7>3`wmRv9dGW`(NRQ> zxCR6vMF4;2yS(WpcSZ5aHmIe~+_Z51&*_3sJ7;B>&Y+g#X9P*k;8(d8=N%}(QDiqY zqJd8Yc)*a3DV8qOGo~?Wp?PFQjFq)==$jSO2p1mZ%$41ndg9(P0N7TKW3+)cAp0Hp@OXD%CV34=LFKg#RvRQ0~@OO z*AlLE+WD59hH5Xc+%t@X`XCt-kR(6Z-3L%9WiT|j*>!gAT#MFF!#muJ1<}}9Wv{u@ z>E1DOY+pv;z}9~u7Ev_7qf5@AhvjdXzYemyDO493guQq*b@w%eCLq-cLgO`Pq&16m zdx?_y#x1`t`cJ$(+27P?+yo7`&Am;waG3Q30ZQ-a(WChSRB^T6Wn@%N5+Z8%$}#i) z1cJ8;g5O9uK^5MMpGG+oQgsIf-Yi}A-QnA-rACRSXC4`sS5#!C3+jSh{$O852{O}{ zM~)mBd3bvP?^}8LQJchz0;dQ}TA=29#}sZ>-b_}o($`4$9=0F${L=)=vx_eFqk%v9Gyscd4;KXj`K?1h{2}$M4nYiv zwx}XpPADB296UFdpCZMB3Ab??Uc1=%2zXXc)2157|B7o zsI~;(8=`Zy|KRzpfe4;q;L++QyYal!RoceWE7r-iY`DMwdFI1ct8fM(AeVZ;@f;{& zmiJTpA`iK($Mv|*rkr}U$>`Xt{mZg#qt#TpH%2@@admZ_>C}G7)$iKp^L(@{?0iV} zo5NGlMH|@yakhT|p9|aUrDrn*C`}OyMfMbh<8hjX0SaLmMQr7&j@VHHUUKG&d9<*p zixhP_36`O<*jS86T44N@U1N@N+W=6GQJz-d&Ep_<9l7soYMm+ zo@Hyy+iiMw>1cG0#hD%1Gn0HI=UYeXJ7^bL2s4d@^sX%DV!xa-W56u6|BTm5Y!R28 z|6(H=8bw|?`mh$Rgo|cWySD*PjE&s|i{}FAVZgrOiWwKzniX5D%!bG8 zWxmFHasPOJJBs^h>c*EI0DJMy*1vLfw17Qd-?!WlUJtXTJWuw;%4h=V^6mWEFFFx6 zpNnuXJ5;P6EDJqg4M5yeK(6Y+^j%=d!HT-%7}td@Yxa(EP$3&eWM+#b!XY=RKcvvq z79$;%U}|jOWvI_IWvdmAKXMk`5woO}Ry73#N)q$3pdg0&=#WxqRb>m}BS$)dSwW z%AmP~ERlW|-#H6T22dzNcKHlRqF~9$t@ON;`O!<;YI7Q?o#ibf0W%YkPEH9LbO?2gkx;E!@QgRq!o` z(gIsoR~Lh}+B<9x$_&XYnS%pC#S(>pU3b9^vR}!@FR9x5w3?)aIu`T=(t)j-stSi+@wl)54X3TyKPB{H zRo}<`kmzUszk@gJgKm3pB5TDtpD-;_GD%Zm|S@P z;WP5=;I~p~R#7s~z&uf^dbkx7=Zp>PtNxrt(8?30j8ic{Xl0*^z+1FzV3n30vHw}> zfr^IRjw$4U1XT2_BR@8%c4M21N-HYdytyg7b*Q5lx*w>3?el|YzxdIz>rlug-ojDy!42cdGxE5X>0p=sDV} z3Z?PaIOX_o3NwyU@`Av}uhMpi?fbl{VcIR3of{n`xVhMaxHtkn=)62k3{dYf4~_#R z_nc5~`*I{WEX?U<0$nPdfe6;A4>4Rj6w9l@jQHc7PYh@OslSOxs8-jn#Z`>W!7U8I z4hm2ZUw2I7rsByN!A+MfY3VTEA?gctS-6s%mSX}QkyAP9wKF8?ldZ7dme7~J7LfR< z0YuyFJ9d;_ainc@nVOo~QC9FP;YQCuJB8cae^HYxwDMVl@X0}xoppl=01A-&}S#DU_%7|+J#I{o3$vabYU0c=z?A%8S1K3!( z3*#AXbhixgH7Op-0aH!hNQ0^I-^)xhj$b)w5fA>*GG*nD|Hkt)5#l{|ocn{+-~we=Zq%X+rDne53lM6hR|sCK3TY#KA9V7;3gBozD<+gcOepLU6%|DW zwq*M@^!PT;Ot%r>Xk9GmfH8_RR{9Mg7bGF1Lh`T=W>xPBPEu~y<W75Oi)5~6?)XIdBrWk=AhhV17=fcQD^Y`CfEOZ0?ZOM2YUWycpV zSKpT5nZt7(?2*ZW0X|ewLnUc8GWcM(BJoB)R`n3<8@h+hfx?voKF|f!toOe%9Y!mM z(E|3>BAPI<^&toPHXYUL*RR`vnkB-TFO0JQ93`d^I|-&$dPvPgTzWpQCWq+?noj}O z51FtToFa*awWJBe-OYngPpS19({d5YXeGUFauR?U&=C$!k)C6)s63 zbCn1}#srQF+OEclYPW3D)m?)+mja0Ehvu^KF)3I_OUxhv*hU^uF?xr6fC>U&z@j)e z=Cu275Oe#CU=7x|(@f-fiHS7{xxO~FPlb_cw{}WWw`=-Pze?01h2Y4f1n)Wo#Pz3wVVI@-?%li7Zax3c3F-0l)AjWYz%CYKNoZSTSIJ)lo~2cz{1LIakKFDNBl!#+6P|2eURVLcSMje|qL z?pi}4clvyKnzumdLU&+4&)CT73sKNPMm>~w;j}V8QWV&1E%o`2_dt=)TN9PiisOYV zSmQW=acn)iuT%LX2{n|l{svRx-Fv@$jD@=s0hycg58@q+Rq6GUWAcVASX~Ry77cj2e%Vl|`^2`QOs%V88NM{J1pRpgBvJwR)vY*? z`V?y8ur_%eF)sm$u9~2@4F9Zm4Ub+N{kr#w#`)yEwZktSA;VKYWr0NlYz*e3 zq_-hk_();Qx{P7qEAx#l3Qfi7{(M0#j09QBa&5E6=A6(LUAm`Ac0FM0Sr}{`*8-&1yRr%rMeK}!PxeiVG`PbZ^rySs z31mByX;eFEApdn=CZgns$jBqukiTa=K=Tw5&`bI8(EMY;<-(4}LHi4qi zXa^AP*PQ{14&tIEN2`~y2H+T?Dz1d9W9(Q3MNQ%wUNzM4SOr|sA+rO(Ueu>=0B~xl zVz-!nPNW+rx4|R!n%rQ!1;{e`8hwg>eRmjcmBkSA_{pWIVrt*_cV+iM=%XzR2cbt=bXuHvV8^1r%z}US>O#) zt0r~@#+j*4D=A@e`cj$J^!s&35NH6e6q z_@eYVuU2OyY#s2C2CWky_wW=j0~P8ciBja=Kn{+;M7?vHHe8lqz5DfOc`srlm!l{0 z?9k~rUMBC;ppbj|NJ7RJtueT?l;k= zr=px4-2!B|xhL*r>awo^uSaUe=euyb~4C_uuh-Jf$%_&<|wJ-R(v`_#c6 zj{941R*toG-Rj94wTFH8&y`YD7opCH)(sTRAc4Ex+`d^Yu|VNJliNcZ{gom?b@vrf zR0t1Fzp<~=3c;qm1(Q;E)FK3{m--ha>K!qrpUB`5=jHvz1UX@X&N;XGgXwQIL7qy_ zgFNV5C#EX|BOYbxQ{3KDCjBhPGIHr}5$W%me@84MVq;H;Z(TUw(MH|1nUL~hfEYVb z9Z1GRj2tGyG?=TL+&bAv1)7_zdz)D&oxuyO^WA%gCzUExmy~!@wF;nm$Q;h_8#nAp ztBbgE*2iGqi4mvq9PE!un>SnbOA+>Z?(e0y6;F7l=o5^)NDu&VeiLW4G@$w(@#nLJnAZK+QOIglW_uM&&2ys$A6H%>}&xqsinmbuFqHg8(?@l<-!J96I~U%IeFJpm%ZH zZQaV3)$e`jjT56d3LC2wK!mBk!v*DJ6IJB#vxvL#*p##JK*VJ~2Up^^zg+i-eD_H; zbg@6Dd!-T3eDiKr?g;)^_OCqC{Bs5x!5_;cVLad`yuxIG*L+-}GpR zU#b*IMMJU*TM6H@#DD@VY5kG7T{$%fCRD-`JkE|Q-8SXeWHdd5dSQ8|ISr$NR+26x z-q~G(wf<5-Kw4aTsX*SJ@a;*vUJi*Rt!Z5uffC6y9@KRdsjefpY+0QMN0S&w7Z=$d zM4vl%F0_e?Ym8j}>&Mq`{BcYa}eTKvr+_DP4#^wFyd3C|Rpt^_#r%Y=v z*WU~3eUC0?+(;a9LGzCK3WfN8K|6AAA&1)DZx=Pk-je(ceOaF&a>RciUTE%3i&j(_o!w~X4LIAer5qPHIF@Pn~HIf`z=pKKlV!e)}`qgsFNAQ>*`Iy*l}Hcd|iET}|! z&v#{IWiH;SGSUK88wxIFJWTav{2jl$Nrvd7V?ow*>(5q{=@Fw-#RL&Qq4(EW)g?}Y-2}>t5#nT%?!cJec6vpaO9qjbH9=c z;<)=(E-kn9Km%6ZktMeQ%H?C$j${3F3@x3n=6vLV`y8(Lq|CuHdY2qFSGh#PhRJL$ zy7wjSHxPGmEoxT>fkZZ11^R755aZj| zJ#&#tSr(w4@#y=TlHAXx>vl^Hq5bxT;n<1Ee*emh34rQO*R&8s`ISCPd^OYeOk9CD ziC>;w$nV!TrOI=2rn!+$7B21-KTyiqXo0W~tC zl$dS9+@V_$+Vbw7PtB269mkIz64mlsU%L*50rS1sSe13}!0Yp~v_^D7nJ?d<0h=laPszo)0iyk33d;WZ*s27-K> zxBAP)7Kk1{Kb^FeZgJK6>gD~OsWQJTQ8({%J%oN;Y_-q~Mm?{9JVbi&>V%%LC3v774qj>hUuYUH<^|QXtV$%WIfAZJ;jrgqe2JXLA2{ee>;6$J zb+`js`ID8Wy?8j4&?vOt&Ijy*J{NPN)>6Fe&Z~32U+3BM@L|Qeu1l>g(YwZMR4pH+ zj@}C5cq}`;D=t1{mWDsV+iAWIT$LFWz~V&S7&3IkkIG3)5%k_qC|$+6?gs(gv>5Mb-li|llXQR9+&azOPQhvEX0pW6DUhNFxl2q<_1Dwy! zl&=kTze-_Y1qY3#EnlBeTzwHG^`o;$l0am?pwyHH@9{EcJdc%ryB=Ky&A;CPy@i@R z!oOT)0^9AE;>d{4(G)k-mT`{RbCU{(1?*Fw3D$4kfwE;|aWt=tVE==;IFB=iF`MrZ z0g86leuTp5;q|nyp=Jt7MC>etJ3iol>e z)5zy^G~YE_9Usa($H!YCA(*Lp`eD$;gePk#sK0Kj(|O1C&CRI^>w=isf_%%aS`~Tr z-?_6Cw$2wrn-+yGrzL5v?_IuK^sLKQ<27z5^KX9Q5SogNGz$9>mtEd9GN(13wpptR zN%z;QvnCJC_`}`s`~O%ZlwM{`pK++~wE+4|~tagXiGT#2KN!Y~xa09BLC4 zf8ZW;v?I2{*33bT1WM%~#V;P&6uPX#XYH{MuZC6_Rx-FoYIGXl;pLqswjnU+66WQO zxOJ{&u!-vVMhO`KxvWAFPQ=VPX(akS;BW+Un9|sx)wRN^wB&}3vrisSugPp0p4PfO z%A}vcr$0~g-aKX!-);0@EBEa&KVs-*^SSW++9n(HXBQox0$N`GVhO-i*HR{$8!VPx zQjvKna&^hxp5)?UsMajSQ$$qr)@kz&n=fCwlsM;K>=W=}5_bJ7db!sb}Ozp(LSwVvDOD0X%=nSl6}?E?cL4=`1iKY>n2PvyV7^$ z^9I}e7he)3I-+e2-<55ZBxODS44Yi&QeEt-JOPwuN}1Ho@r101$j*&+IKstU)BV7V z(%rIUOO@*ZxL%KE6&ERUy}q?VxXp{})og4_0TGOqB2Cjp;6IOZ)vqw+#l5+a)Yb9obw}Yga~FA8&o!^g7F4F#hB{v%BecIv z+-nH{HDPBdle6n&*DMMs^Ur;p|-3vEnR=T3`ek1+ja`-F}eT z9oZKvb6qgGdOFBFEdWqWyl|CxXY4~}(VctuxO?1AU*4+KJ~iw=RlBUo=?zDB@dOeA zOR_W};O0?m+r4x6*D(LEHOhkRnf3PR7Y}-T<#O+FP~~$@9ZY}d_h=7DV?C1_zgopl zbswBs=qOr#u64%F_w&uAp3b3%1~v3uF)`g;yToCE4iL7f3^u5*@;Zg(U;wH5Uk3Hv{@y?}c&@eR~yX3MfL3RJu zSmpSZZ}Yu9@9XAD{gV}YYF%w$Ntjpa zk7APp^vcXYs_<452_41Izw^)E^IMzgO&K}P0X7BGd-%w8mfjhO(E^05`S@5=|L>mW|0-gV^V-u#3Z zH}YDDS4<0Q3lNm{3_$WSazRvm?$_lT4R!SNGCFjI)%L?$%gq#2IbS?}S%$gQJy_4U z#gVeip$>|+zN-QUFs-+&$)YCnp1g8J~q3d!Fuq&4OJzSY&$zOetwEXAVk zHp6lHT-W~yq|k?kGjM!*>bvp^2*9EK)!#=S1`tM%Q!u(x9aIFQZxt?S%KK_}SQRBJ zpbg6VwMdZ*zN4(`?C;Zvt`Db2cxR>JwmDjfAkqHF(%q1+_4n7sKKF4%Nx#f0l%@P~ zkv!R}%?G!ff0=hLRJZP6NM(1Eg?FDK#zr!>g^3B0HKQ5*g8Cemj3Pq!1!|sI3PEJ6 zI%qI#7pEC0&JvN{OhskmCUif47-RRY7kxFyV^@Awlk=5U`+es9=}?4k zi`G81hmei*qx;aGIP~EhdOSD*UZv1e_d&B}(m9^BLgCnqF5Sz-*LP`qHl$Ggv=ed_`)X_Q1zf0~Jv|JLy>S-~l^VFsU z9FZ`+;1$5$_+xDi+%UeUf+??5|0vO7FTle_XAQI!OcI?Pe@9Z)poH2V9cW|UBM2_1 zjzzpMXTW0q8ErEJeq8*vai&JwG>+W0T-P8}z8yZUfKKaK!$HG|@kKZUeTpxNuL@cc z_(DK{m3|FdmS=wiBVWLX&k7($X7|wwyE#4|W@Z~SUdCyyNhxvG$8zs#1<8zOTrcr9 zlU}6VIJ8hv@Q1_seJ;hw)@m1bzgE6`a{5|T!O|!3&C*?8TRCxm{y5aP-SRcn4fZYl zanqzjgZW!#^;aD64zszBUFZU08I1vRN&1TNZh&4%y650gs_#ae;}=`6TKV96Lg^cF zAW$|R)e{br!d;dM7VNW###R2)=+lGm`_bF8*}m%X7@{1x1p6(5H4U}@4ju76WAZ(H+P zF)GCq_X_5kRbZK@etOQqxJ#|SrQ4~1gqWE+2$zwU3T2~Y8T)N;^l+YSXab#e#$B$6 zzMEAuB~^<%fwIY|6d%L3!D<`}LJ09+9yi79+PdKbdBf3sT|e-4$>SzbhV+$^SLE4I zppMQ9<>cku?Fhw3WhS3L+)h=tQI*CX?wRTEuO5U5Nu>{~|{GDYdDI!E&i<=7+^?LyM3v<7cwBegezd zJ^j$jX+{v;5N=uQ<*t)Qo@3~4{8{spAP0l_n;hOR~P&$}U`$;eaeifgceu6YJltGBfAXWl*1JNGs-;97(ep=ZqctF-e zraJe=P5?|(dqW1$2(E!x9E5!Ly+qg9W3!9Y1jN?cm^rCKek2PoHEPUE-8{xk2NMo0 zni?tAI?=>!wW)R-1g#oEmol#?#+tH|W!HEEdd8GfAE&I^?jP50!gZAG;x(5Cu|6Em zP!P=9m+32|UgHF+;ByVlbywFN0A>6h2@y;>bauNP@cpyr`IPLu`yxaj%9J*l&Qklw5r0dP=$G1#oFNU`_7tyRSW^ChZh{YtPF_up_a zeNgEuToR18-Baw zVw-0w9o*gU(yOYa(ruxl3d1~_JD#+G; zxUFqG8L_GW?7W_Q%+%c3j-vNONJ5qc$oC*(q2RuME+k*DlfXeZ5Qph5c`?v_T_(`G z=_3nq)(!KEYp(y1NtJzhNQ^6;f5W|D9Fd{$Z&xtrd~lkiuUfKXNy`w1?MWK;Qb{fJ zqimS<`1u~lF7_nM~*{gR&hR@IAJp6 zTX1Ypd&@^*xizhsKxONnr1maSKHfGwhS7YI$F*i-(BMKs=*eG5{-w@~OhA|Qmpx16 zv#t|Kz(DifKBJE%DRm3h=rv4@`%hKs+a^}VcZzQAPn-PEvFGxZ^0jH7bbVLqt-4oh z(9f;m52Ei_-e1&?b&nsvKZ+fxX9~AE-n>!%3wFK74QQ~zZQP-YZ}O`Hw2 zAg6vJ(h>>OGTXXhldhO6=_Oq;B20gLY@L5X)bcgzfnEaCmSRXPq6kKW&A`i}(5t5c5fa3cCR zi+GIg28x6|AKX!J;k4ILRr!}xHtE=;u>#2Y(aMk5)OvpL)Wn(ds%?H)o#4{@JHc@J zN&T)p3HDQG=fwhk99q9zN~+HJwy~S%*VFS(%oLWt7@4(v+kxtN_qoqEZMAwgs@bJ9 z31mg(VyIVM((+TEqR@F){@Vea!eLak>Wat2y5}b^KjpRf$Ms1huTLJ10=ibrYIHLz zY*JjeT;GBBk2A36J9-I8{lB!PMt6Gaz3o;_nio=0kiT|a)|Z2#d#@w|p{<+zT;N~e z38#2FwVy+wvwCk<+^h)mn*L)V<=(l!X}whT9{)mUs_)-u+tha%GrQRCcGu&~v8xQs zH(~@xhbmkLFLap(cW!+XAZEYP+=s!`1%4Gm69-Q)pZie?ajPXB^`9}K%s-z%UO8lC zWi={~g`PrP72UG;m7qqvOIm|FwjL@a7vGRr`Ko#5jvG>ky`d`Jm??D7rChmh0PViY z^6e`=8TaQmzyQy$)FsoV`#OXTD{9B7dx@m?YL8|Dq6aq1?yd}^Zh;zmsVX6lwpF!5 zO*iHpAKi~SPnFBvdTfI?yG{Dh&u=f<&A7+G0Cq&)AEk^2E7+&~A_}R)DE3KTswFj# ztKt{t%J&9(WMSFlP?y;3Prv#~@f01U7wH)4{diVPx59a%SnKcVT6gLp?5A;Zw?N*> z*j>AJ^*ll8a};q!M7OVsLsJh?4fk*DZa7ceVPPt~fI z+C7}&CsTu5Z`P{q9v-sc;)XsfFQD6I%5`PN1=)|6K6DV3t?|H|CX3vTJsXz=`HJr` zG&Xp{tKz}IvoPUnZb-;>P9=!U-8J?+$%{RtB->A@@Uy&pIRJ-z`!3*MJ|Euk`Z3sa z!|5{+Sv!FC{;@$)uZWzaJdG!`Mp`)BMKz`|ghhCHL0aE+&HPMGpnA>uFZFXw8` zA$r^cpnsnw{{lzpW7malayb`q&HH@ha94ZE%A(Vo8*gzKW}Q5fvyQu~FRiaaKS7m0 z>*ubu9${Oy<#Wx!r! zfp({dpQEXVj8RbPJ(=;76&$|=9Dey#U@ks;X&z}%xxMOIM8%;;gQ7D0{O=8}GoMaX zSbsE#*>>#N`J7+>T;>SbSSr8Z!IJg+Y4-I)}%V9l$I8~HbF+=k|EeJfqWY&|wV`i4qsEzA8q zCd7a<;g+-j|3Nm46~)xI*4$WY5MP+g8T!WkOafm<)8`wu=CM%2B#k)CX*a4GVA0wUcj4q^8(JV&*KJ4s+;P; zbZyY_1j;{Pe$e#y=Ft^vhSWb)cKW=Dy;*IRAH-!KqBf}Ab}dNqR*+@146~-L&@7OD z_}Mz7ui~Fn?q5Hn2xex%ajE@pb0#E$nMyW45=|A@4fCgOt%%ZRFh)0OYvVUZDpY-a zsGzF#3Ig*Ja{afYC8PJf{#f$ESHs#%VbA?5>*YTMWhin_7e2KktYH)H^uhZ*9^ZQc z1(Obo$Znfeanj{|tIrn0%Q=pTuD|{C_tY#NjcwvQ)l}KZDeas&HZ9@FBL1eUznQrm z>p!@(>W(iDykMyR)#qAn@n$3aX$fxFXh_SmNa6|fBZ&fy7soLK+ zI=1DniXJn}d+Eulp_>1u5dUzYaV2h+Vb$n5a;<4g(tzUNlh=?T9# zHvam1rnzK(=0Bq{*IxX@voj$THlKL< z9Z%VajoVyV!o`N_*b&+S)W3zYyW|`@I;)Y4r-uzU?x-mINwaG@y zjdppMc_?q%R4pAM*U&fc_2)F_$6*sIzsbQp74 z=K9a<4kaB`B^{gf&o#fHem4K(x83coK-g;Q zvXE2fQP*A5jk(#7ad5E){W_J*silK`mV@=#JZ9Zn0z1Rg{@_62imfY**)s_Pe7>B# zH#2NkJXOr{zUMNSsW>erRX1!lcADdd->E;mxo^eB(b9u|TQi-#?p;{XSF>c3#M!X#lgx6nW^hkmm~8F?C0^n)TW~nXRU0e}lN1vz`*oM!_mR{O z&<~p#cStjOv4Tk@#~(j5`gCAlY1C5rPAa}{qA5y0_xAzRvyN9C6Nqz3YT^nEfg`Ow z+UM*tyAZjSS-(z6!5pCO5R%NN@%6U%H!rt7>#nn9RlloL+R}euEX+!dwfBcC*S&6$ zn%%x|7yyCy%Dr+4UP7wdIX}LXFnMhSCRyLilX`j`arZ077cLxMB)#ZaV)ppWsZnvL z67TO?ApOlV{_G8*aH45!j?T~$^wqasG1B~ZC@X%BHJ4>9vlNlHy_ww=)Hq0stBc(*%X`nK%~s~M;jzUg z{TezlKVm}0mQqxGrff1Yi@0SS9^P9 zjdRKk_WQBPt+yD=N3{lfzmwn*^3hSLZPh~+Gk>F^`N8*Epn_*lbO zsz%LgxkQ7dGE`w(M^kU$Wso$aph^BMwC0oLIgy+vi9%V zbvX^imi$wvI14&Z!|V)5yL975-wr*!KW3n(4CEud8_^&MIM|2d6Pe{;d+^U!kf_ZE zQijP!RI2Aw%`mfWwl#NpJ*V%9MgGLV4tG(}QVUq)+14II!7kMb62g37VKoj~5fKqC zqA5h10v^_DUT<6QvX1-xxUEL-PJK{pD_wI>&UkU9){lH)YQc6dsIARLStp^{g-Z0K zlRsiw?wOiPiG8X&Qj(5+n#8Vwf*|N>Kd2mIn&Ul6!&46VqeoL~VLhnsY?1j_s_X2(BXX?}OT?_88|MF0#ilv`|V)H8JMOa^xy!u z{(!P;FFX~V;`q}~&92U<)LG~DW`XsHMt^RWmy*o{nab%sNOs*N-@a4hPvp2-&C4S1 z2Q}L3!IO5Wv${6ti=RynkZ!~2sTZ=m_DH|Cd@3yycllLv_p@rf{T&|`Y>8LA-Phh# zwCj4u=7$XnjpJXrbd|>s?`ij!YB5^HE)_p`A!TNy&rbQKS?MsL8&;>JOQe_Wh zlcrqXnh4sRduu_W*sQj#z+H2o{YrUpoep1QLn}pPLAQvxeUW5jXauhiZS~WA>ep7e zHEgw#M9;C6+Ukufm||tBX0R+a`~=*dwG*^)>2HmtefR?4G@1r`Kg@mHcx=B6&%ae+to)a*qIx|+Cw*YPGq0N2E7l)QSjZd&Tul!YzD3!{#ksvPyq z2%iJI;vKlRB_Z=9ORnfg90*=3RX7_E&t2hUW{=wG{H! zo0kH2ku?Ly)j+XHi)D?Sx$Bu4mcPU(A zBfsb#uVuQrAS;i_v3lTO=(qo;)-Q}>JKCF8aPfmY@Ar?-YJlVPGKYctZyY8DI6PR? ztyeTTEX&I~^i#;eYbLd`lFrYZ6rg)&*0-R`?$cOKbsJ<~)49Fst?cvom^RTD5m`C= zg!f#SQTvqrf&f2j@{~DOW=;zTuE|>Epz5USCt?${Pngx>geh=sG^j1{zybdfLC${aD*Xe)7gY6M*_2dTH#1#=k--JHj~jRx#*!&Z{{gq?Zj$_Mo_8lh`)t*e$c&@5;;al)T;MB-Ra8K`%_vwv4AM;x?FWu*|Ia^Tp;PJ9^ZpJXJ3U?fT-G@y GGywo8p2|)D From 203002c55347af288e18171998647773a13c909c Mon Sep 17 00:00:00 2001 From: Ray Date: Mon, 3 Aug 2026 01:02:32 +0800 Subject: [PATCH 20/23] Sync PageIndex Flash from private branch --- pageindex/flash/README.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/pageindex/flash/README.md b/pageindex/flash/README.md index 29bafdb01..a5463a4e9 100644 --- a/pageindex/flash/README.md +++ b/pageindex/flash/README.md @@ -1,8 +1,8 @@ # PageIndex Flash Builds the PageIndex tree structure from a PDF using layout statistics without -LLM. Augmenting the tree with summaries and refining it for retrieval needs an -LLM. +an LLM. Augmenting the tree with summaries and refining it for retrieval needs +an LLM. ## Usage @@ -11,13 +11,13 @@ LLM. ```python from pageindex.flash import page_index_flash +tree = page_index_flash("paper.pdf") # structure + node summaries tree = page_index_flash("paper.pdf", summary=False) # structure only -tree = page_index_flash("paper.pdf") # + a summary per node -tree = page_index_flash("paper.pdf", optimize=True) # + retrieval refinement +tree = page_index_flash("paper.pdf", optimize=True) # also refine for retrieval ``` Takes a file path or an `io.BytesIO` stream and returns the tree as a dict. -Summaries are on by default and need an API key. +Summaries are on by default and need an LLM API key. ### Command line @@ -34,7 +34,6 @@ Writes the tree to `results/_structure_flash.json`. { "doc_name": str, "doc_title": str, - "has_abstract_or_references_section": bool, "structure": [ { "title": str, From 518b26a148440237729136401f5524259745f445 Mon Sep 17 00:00:00 2001 From: Ray Date: Mon, 3 Aug 2026 01:11:16 +0800 Subject: [PATCH 21/23] Sync PageIndex Flash from private branch --- pageindex/flash/README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pageindex/flash/README.md b/pageindex/flash/README.md index a5463a4e9..e90f053a9 100644 --- a/pageindex/flash/README.md +++ b/pageindex/flash/README.md @@ -11,9 +11,9 @@ an LLM. ```python from pageindex.flash import page_index_flash -tree = page_index_flash("paper.pdf") # structure + node summaries -tree = page_index_flash("paper.pdf", summary=False) # structure only -tree = page_index_flash("paper.pdf", optimize=True) # also refine for retrieval +tree = page_index_flash("paper.pdf") # with node summaries +tree = page_index_flash("paper.pdf", summary=False) # tree structure only, no LLM +tree = page_index_flash("paper.pdf", optimize=True) # with node summaries + refined tree ``` Takes a file path or an `io.BytesIO` stream and returns the tree as a dict. @@ -40,8 +40,8 @@ Writes the tree to `results/_structure_flash.json`. "node_id": str, # 4-digit, zero-padded "start_index": int, # 1-based, inclusive "end_index": int, - "summary": str, # with summary - "key_items": [str], # with optimize: titles merged away + "summary": str, + "key_items": [str], # optimize only: titles of subsections merged away "nodes": [...], # absent on leaves } ], From 4a2e13b72a69e811a53e1a020a1c8be9de5408ab Mon Sep 17 00:00:00 2001 From: Ray Date: Mon, 3 Aug 2026 01:12:49 +0800 Subject: [PATCH 22/23] Sync PageIndex Flash from private branch --- pageindex/flash/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pageindex/flash/README.md b/pageindex/flash/README.md index e90f053a9..30b1ddab2 100644 --- a/pageindex/flash/README.md +++ b/pageindex/flash/README.md @@ -53,7 +53,7 @@ Writes the tree to `results/_structure_flash.json`. Nine PDFs, each run end to end with tree optimization: PDF parse, layout outline, merge, LLM expand, then a summary for every node. -![Time against document length](assets/time_vs_pages.png) +Time against document length | Document | Pages | Input tokens | Output tokens | |---|---:|---:|---:| From 189ec1ac9d6baede8ac47936ba4824fe21edc6f9 Mon Sep 17 00:00:00 2001 From: Ray Date: Mon, 3 Aug 2026 01:13:17 +0800 Subject: [PATCH 23/23] Sync PageIndex Flash from private branch --- pageindex/flash/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pageindex/flash/README.md b/pageindex/flash/README.md index 30b1ddab2..f747030fc 100644 --- a/pageindex/flash/README.md +++ b/pageindex/flash/README.md @@ -53,7 +53,7 @@ Writes the tree to `results/_structure_flash.json`. Nine PDFs, each run end to end with tree optimization: PDF parse, layout outline, merge, LLM expand, then a summary for every node. -Time against document length +Time against document length | Document | Pages | Input tokens | Output tokens | |---|---:|---:|---:|