Skip to content

PDF text selection: mixed Hebrew/English skips words and copies out of order #281

Description

@yaacovcorcos

Problem

In Scient's PDF reader, selecting Hebrew text that contains an English word can skip that word, jump unexpectedly between lines, or copy text in the wrong order. The visible selection and the intended reading range can diverge.

Affected surface: PDF text selection and copy/paste. This issue does not concern the Markdown editor or Markdown rendering.

Track the bug independently of any implementation: a custom selection controller is an investigation candidate, not an accepted or validated fix.

Reproduction and expected behavior

  1. Open the synthetic hebrew_rtl_selection_test.pdf in Scient's PDF reader. Its complete HTML source is preserved below for regeneration using Chromium's Print to PDF.
  2. On page 1, use T-01. Begin selecting inside the Hebrew sentence beginning, extend across the embedded PDF token, and continue into the second Hebrew line. Repeat in the reverse direction.
  3. Copy and paste into a plain-text editor, comparing the selected characters with the source. Capture the exact clipboard string and selection endpoints when qualifying a fix.
  4. Repeat with T-02 (Chromium, HTML, PDF, Unicode, pdf.js) and T-11 (English code embedded in Hebrew).

Expected: selection follows the intended sentence order across direction changes and line wraps. The highlighted characters and copied text correspond to the same range, with English tokens intact and no skipped text, wrong-column text or unintended line jumps.

Reported: selection jumps around embedded English and line boundaries; copying does not reliably preserve the intended order. The user's screenshot shows the T-01 selection. The original screenshot's temporary file is no longer available and is not attached here.

The opening source text is:

מסמך זה נבנה כדי לבחון את שכבת הבחירה של קורא ה‑PDF בטקסט עברי.
כל מקטע ממוספר, וכל שורה נכתבה כך שניתן לסמן אותה, להעתיק אותה ולהשוות את
התוצאה למה שהוצג על המסך. שבירת השורה היא המקום שבו סדר הגליפים בקובץ נפרד
מן הסדר הלוגי של התווים.

Source newlines above are HTML whitespace; distinguish visual line wraps from paragraph boundaries when evaluating copied text.

Investigated baseline

  • Investigation: 2026-09-10, macOS desktop environment.
  • Scient commit: 415347ae2667d2579ed1f1d2d16aca521584e960.
  • Pinned PDF.js: 6.2.108, legacy viewer/build.
  • Original synthetic PDF: four pages, tagged, document language he.
  • Original PDF SHA-256: c3abe272b100aca4b81a9790463c8a4e9006a4ef3a2d89ed8e19900fd6bff61d.
  • Extraction used the actual text-layer options: includeMarkedContent: true, disableNormalization: true.

The original PDF is retained locally under its fixture filename; it is not attached to this issue. Regenerating the embedded HTML preserves the test source but may change pagination, text-item indices and PDF bytes depending on Chromium/fonts/print settings. Treat the hash and exact indices below as evidence for the original PDF, not as properties guaranteed for every regeneration.

Confirmed mechanism

PDF.js positions text spans visually but appends them in extracted item order. It assigns each span its own dir. Its copy path takes document.getSelection().toString() and applies its existing normalization when copying is permitted. Selection painting also derives from the native DOM range.

For the original T-01 paragraph (p2R_mc18), extraction order is:

Text-content index, including marked-content entries Content Intended order
128 Hebrew ending of first line After PDF
131 PDF In the middle of first line
134 Hyphen Before PDF in logical order
136 Hebrew beginning of first line First
140 Hebrew second line After the complete first line
144 Hebrew third line Last

A native range beginning inside item 136 and ending inside item 140 cannot include item 131. Visually positioning these elements does not change their DOM relationship. This establishes the ordering mismatch; exact pointer transitions and clipboard output were not re-recorded during the source investigation.

Paragraph tags identify boundaries but do not resolve this paragraph's internal mixed-direction order. TextItem.dir alone also does not determine how adjacent English fragments and neutral punctuation belong together.

Related: #274 corrected stray blue selection painting at the page edge. It did not introduce or fix this reading-order defect. Preserve that paint fix.

Source at the investigated commit:

Pinned dependency inspection: TextLayerBuilder copy handler and extraction options, TextLayer item append/direction, TextHighlighter item-offset mapping, Autolinker native-range construction, and DrawLayer selection rectangles.

Rejected shortcut and regression evidence

A disposable experiment sorted rows top-to-bottom and each row's text items right-to-left, preserving each item's string. It restored T-01's sentence order but caused these failures:

Case Correct token/expression Naive RTL sort
T-02 pdf.js js.pdf
T-04 (parseInt) )parseInt(
T-11 const dir = "rtl"; "rtl"; = dir const

Do not reverse characters or globally sort the page by coordinates. Those approaches can damage English runs, punctuation, Hebrew character clusters and column order. Moving the existing PDF.js DOM nodes also needs particular caution: search, autolinking and accessibility share item/node mappings.

Candidate approach — not yet implemented or validated

Explore a bounded logical reading-order model and selection adapter:

  • Preserve PDF.js's original text items, DOM, source offsets and rendering responsibilities.
  • Map qualified paragraph/line/run reading order back to original character segments and geometry.
  • Use the same segment list for visible selection and copied text. A clipboard-only correction would leave the jumping selection.
  • Preserve LTR runs, numbers, neutral punctuation and grapheme boundaries; explicitly decline ambiguous cases instead of guessing.
  • Preserve native behavior for unsupported regions, with tested handoffs so a partially supported selection cannot silently omit text.

A custom controller adds input/accessibility/lifecycle complexity. Prototype and qualify it before choosing this approach or enabling it broadly. The issue remains valid if this candidate is abandoned or the eventual solution is upstream in PDF.js.

Acceptance criteria

  • T-01/T-02/T-11: forward and reverse drags, partial English words, boundaries before/inside/after English runs, and selections across line wraps preserve the intended text.
  • Highlight rectangles and copied text represent the same source segments, without skipped words or extraneous lines/columns.
  • Mouse, Shift-click, keyboard extension, Select All, context-menu copy, cancellation and focus changes behave coherently.
  • English-only, Hebrew-only, Hebrew inside English, numbers, decimals, percentages, URLs, code, nested brackets and Hebrew combining marks show no new regressions against recorded baselines.
  • Tables, columns and an actual cross-page paragraph are tested. The original fixture's T-14 is entirely on page 4 despite its label; it is not a cross-page test.
  • Search and link geometry, forms, existing annotations, accessibility, SyncTeX, PDF copy permissions and copy from unrelated controls are preserved.
  • Zoom, fit, pinch, rotation, resize, page virtualization, source renewal, file switches and multiple readers do not invalidate selection identity or leak listeners/caches.
  • Automated real-browser interaction/clipboard checks and targeted native Electron visual review pass on the exact candidate, followed by applicable repository checks and user visual acceptance.

Verification boundary and related finding

The existing focused PDF suite passed: 15 files / 100 tests. Those tests are baseline coverage and do not prove mixed-direction selection correctness. All four PDF pages were rendered and inspected as documents. The probe below independently verifies the ordering mismatch and rejected-sort counterexamples; it is not a browser interaction test. No product fix was implemented or enabled by this investigation.

A separate extraction-fidelity finding concerns page-stream /ActualText replacements for hyphens, brackets and Hebrew marks that the pinned text-content path does not expose. In T-01, a source U+2011 is extracted as U+002D. Track that separately; fixing selection order alone must not be described as restoring all source characters. Annotation creation was disabled in the examined checkout and is not added by this issue.

References: Unicode UAX #9, PDF.js ordering report #17191, PDF.js Hebrew extraction report #20336. These upstream reports corroborate the problem family; they do not establish a working fix or upgrade.

Complete synthetic HTML reproduction source

Save as hebrew_rtl_selection_test.html and print to PDF with Chromium. The fixture's descriptions and expected-output notes are test data; validate the resulting layout and text rather than assuming the labels prove coverage.

<!DOCTYPE html>
<html lang="he" dir="rtl">
<head>
<meta charset="utf-8">
<title>גיליון בדיקה — עברית, כיווניות מעורבת ושכבת הבחירה ב‑PDF</title>
<style>
  @page { size: A4; margin: 18mm 16mm 16mm; }

  :root {
    --surface-1: #fcfcfb; --page-plane: #f9f9f7;
    --text-primary: #0b0b0b; --text-secondary: #52514e; --text-muted: #898781;
    --grid: #e1e0d9; --axis: #c3c2b7; --hairline: rgba(11,11,11,0.10);
    --accent: #2a78d6; --accent-soft: #eef4fb; --warn: #b03030;
    --he: "Arial Hebrew", "Noto Sans Hebrew", "Times New Roman", sans-serif;
    --he-scholar: "Arial Hebrew Scholar", "Times New Roman", serif;
    --mono: "SF Mono", Menlo, Consolas, monospace;
  }
  * { box-sizing: border-box; }
  html { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
  body {
    margin: 0; font-family: var(--he); font-size: 10.5pt; line-height: 1.62;
    color: var(--text-primary);
  }

  .masthead { border-bottom: 2px solid var(--accent); padding-bottom: 9px; margin-bottom: 14px; }
  .eyebrow { font-size: 8pt; letter-spacing: .06em; color: var(--accent); font-weight: 700; margin: 0 0 5px; }
  h1 { font-size: 19pt; line-height: 1.28; margin: 0 0 7px; font-weight: 700; }
  .byline { font-size: 9pt; color: var(--text-secondary); margin: 0; }

  .notice { background: var(--accent-soft); border-inline-start: 3px solid var(--accent);
            padding: 8px 11px; font-size: 9pt; color: var(--text-secondary);
            margin: 0 0 15px; break-inside: avoid; }
  .notice strong { color: var(--text-primary); }

  h2 { font-size: 12.5pt; margin: 19px 0 8px; padding-bottom: 4px;
       border-bottom: 1px solid var(--hairline); font-weight: 700; break-after: avoid; }
  p { margin: 0 0 8px; orphans: 3; widows: 3; }

  /* Test-case blocks */
  .case { border: 1px solid var(--hairline); border-radius: 5px; background: var(--surface-1);
          padding: 9px 12px 10px; margin: 0 0 11px; break-inside: avoid; }
  .case > h3 { display: flex; align-items: baseline; gap: 8px; font-size: 9.4pt;
               margin: 0 0 6px; font-weight: 700; color: var(--text-primary); }
  .cid { font-family: var(--mono); font-size: 8pt; font-weight: 700; color: var(--accent);
         background: var(--accent-soft); border-radius: 3px; padding: 1px 5px;
         unicode-bidi: isolate; direction: ltr; }
  .what { font-size: 8.4pt; font-weight: 400; color: var(--text-muted); }
  .sample { font-size: 11.5pt; line-height: 1.7; margin: 0; }
  .sample.big { font-size: 14pt; line-height: 1.75; }
  .scholar { font-family: var(--he-scholar); }
  .expect { font-family: var(--mono); font-size: 8.2pt; color: var(--text-secondary);
            direction: ltr; unicode-bidi: isolate; text-align: left;
            margin: 6px 0 0; padding-top: 5px; border-top: 1px dashed var(--grid);
            white-space: pre-wrap; word-break: break-word; }
  .ltr { direction: ltr; unicode-bidi: isolate; }
  code { font-family: var(--mono); font-size: 9pt; direction: ltr; unicode-bidi: isolate;
         background: var(--page-plane); border: 1px solid var(--grid); border-radius: 3px;
         padding: 0 4px; }
  a { color: var(--accent); }

  ol, ul { margin: 0 0 8px; padding-inline-start: 22px; }
  li { margin-bottom: 3px; }

  table { width: 100%; border-collapse: collapse; font-size: 9pt; margin: 4px 0 13px;
          font-variant-numeric: tabular-nums; }
  caption { text-align: start; font-size: 9.6pt; font-weight: 700; padding-bottom: 5px; }
  thead th { text-align: start; font-size: 8.2pt; letter-spacing: .02em; color: var(--text-muted);
             border-bottom: 1px solid var(--axis); padding: 5px 7px; font-weight: 700; }
  thead th.num, tbody td.num { text-align: end; }
  tbody th { font-weight: 500; text-align: start; padding: 4px 7px; }
  tbody td { padding: 4px 7px; color: var(--text-secondary); }
  tbody tr + tr th, tbody tr + tr td { border-top: 1px solid var(--grid); }
  thead { display: table-header-group; } tr { break-inside: avoid; }

  .twocol { column-count: 2; column-gap: 9mm; column-rule: 1px solid var(--grid);
            font-size: 10pt; margin-bottom: 10px; }
  .twocol p { margin: 0 0 7px; }

  .endnotes { margin-top: 18px; padding-top: 9px; border-top: 1px solid var(--hairline);
              font-size: 8.4pt; color: var(--text-muted); break-inside: avoid; }
  .endnotes ol { padding-inline-start: 18px; margin: 0; }
  .pagebreak { break-before: page; }
</style>
</head>
<body>

<header class="masthead">
  <p class="eyebrow">גיליון בדיקה פנימי · RTL-TEST-01 · 10 בספטמבר 2026</p>
  <h1>עברית, כיווניות מעורבת ושכבת הבחירה בקובץ PDF</h1>
  <p class="byline">מסמך בדיקה סינתטי — נבנה מ‑HTML ונבנה ל‑PDF במנוע Chromium בהדפסה</p>
</header>

<p class="notice"><strong>מה נבדק כאן.</strong> כל מקטע מציג טקסט עברי בתנאים שונים, ולידו
המחרוזת שאמורה להתקבל בהעתקה. סמנו את הדוגמה, העתיקו אותה, והשוו לשורה המסומנת
<span class="ltr">expected</span>. אי‑התאמה מעידה על תקלה בשכבת הבחירה, לא בטקסט עצמו.
כל התוכן במסמך מומצא לצורך הבדיקה בלבד.</p>

<h2>א. זרימת טקסט בסיסית</h2>

<div class="case">
  <h3><span class="cid">T-01</span> עברית בלבד <span class="what">— זרימה ימין‑לשמאל, שורה נשברת</span></h3>
  <p class="sample">מסמך זה נבנה כדי לבחון את שכבת הבחירה של קורא ה‑PDF בטקסט עברי.
  כל מקטע ממוספר, וכל שורה נכתבה כך שניתן לסמן אותה, להעתיק אותה ולהשוות את
  התוצאה למה שהוצג על המסך. שבירת השורה היא המקום שבו סדר הגליפים בקובץ נפרד
  מן הסדר הלוגי של התווים.</p>
</div>

<div class="case">
  <h3><span class="cid">T-02</span> עברית עם אנגלית משולבת <span class="what">— מעברי כיוון בתוך שורה</span></h3>
  <p class="sample">מנוע הרינדור Chromium ממיר את ה‑HTML לקובץ PDF, ולכן סדר הגליפים
  בקובץ אינו זהה לסדר הלוגי של התווים בתקן Unicode. ספריית pdf.js בונה את שכבת
  הטקסט מחדש, ובדיוק שם נשברת ההעתקה.</p>
  <p class="expect">expected: …the words Chromium, HTML, PDF, Unicode and pdf.js must each copy intact, in logical order.</p>
</div>

<div class="case">
  <h3><span class="cid">T-03</span> מספרים, אחוזים ויחידות <span class="what">— ספרות אירופיות בתוך RTL</span></h3>
  <p class="sample">בשנת 2026 נסרקו 1,240 עמודים ב‑12 מסמכים; שיעור השגיאות היה 3.5%,
  ירידה של 0.8 נקודת אחוז לעומת 2025. גודל הקובץ הגדול ביותר היה 706 ק״ב.</p>
  <p class="expect">expected: 2026 · 1,240 · 12 · 3.5% · 0.8 · 2025 · 706 — digits in that order, decimal points and commas attached.</p>
</div>

<div class="case">
  <h3><span class="cid">T-04</span> סוגריים ומירכאות <span class="what">— שיקוף תווים דו‑כיווניים</span></h3>
  <p class="sample">הפונקציה (parseInt) מקבלת מחרוזת [string] ומחזירה מספר שלם;
  אם הקלט ריק היא מחזירה <code>NaN</code>. השאלה הפתוחה — האם ״הסוגר נפתח בצד הנכון?״ —
  נבדקת כאן במפורש (וגם בתוך סוגריים מקוננים [כמו כאן]).</p>
  <p class="expect">expected: parentheses and brackets keep their logical pairing after copy; the mirrored glyph order on the page is not the copy order.</p>
</div>

<h2>ב. ניקוד, טעמים ופיסוק עברי</h2>

<div class="case">
  <h3><span class="cid">T-05</span> ניקוד מלא <span class="what">— סימנים משולבים על אותיות</span></h3>
  <p class="sample big">שָׁלוֹם, כֵּיצַד נִרְאֶה הַנִּקּוּד בַּקּוֹבֶץ הַזֶּה?</p>
  <p class="sample big scholar">שָׁלוֹם, כֵּיצַד נִרְאֶה הַנִּקּוּד בַּקּוֹבֶץ הַזֶּה?
  <span class="what">(Arial Hebrew Scholar)</span></p>
  <p class="expect">expected: each base letter copies with its own points; no marks dropped, reordered, or emitted as standalone characters.</p>
</div>

<div class="case">
  <h3><span class="cid">T-06</span> טעמי המקרא <span class="what">— בדיקת כיסוי גליפים U+0591–U+05AF</span></h3>
  <p class="sample big scholar">בְּרֵאשִׁ֖ית בָּרָ֣א אֱלֹהִ֑ים אֵ֥ת הַשָּׁמַ֖יִם וְאֵ֥ת הָאָֽרֶץ׃</p>
  <p class="expect">expected: no tofu boxes. Cantillation coverage is font-dependent — this line is the coverage check.</p>
</div>

<div class="case">
  <h3><span class="cid">T-07</span> פיסוק עברי <span class="what">— מקף, גרש, גרשיים, סוף פסוק</span></h3>
  <p class="sample big">עַל־כֵּן ר׳ יוֹסֵי · תשפ״ו · כ״ג בֶּאֱלוּל · דו״ח מ‑ג׳ בטבת ׀ סוף פסוק׃</p>
  <p class="expect">expected: U+05BE maqaf, U+05F3 geresh, U+05F4 gershayim, U+05C3 sof pasuq, U+05C0 paseq — each copies as itself, not as ASCII - ' " : |</p>
</div>

<div class="case">
  <h3><span class="cid">T-08</span> אותיות סופיות וליגטורה <span class="what">— צורות תצוגה</span></h3>
  <p class="sample big">מלך · אדם · בן · כסף · ארץ · ﭏ (ligature U+FB4F) · שׂ שׁ אַ אָ וּ</p>
  <p class="expect">expected: final forms ך ם ן ף ץ copy as final forms; U+FB4F does not decompose into aleph+lamed.</p>
</div>

<div class="case pagebreak">
  <h3><span class="cid">T-09</span> הדגשה ונטייה בעברית <span class="what">— משקל ואלכסון סינתטי</span></h3>
  <p class="sample big">רגיל · <strong>מודגש</strong> · <em>נטוי</em> · <strong><em>מודגש ונטוי</em></strong></p>
  <p class="expect">expected: weight/slant changes do not split words into separate copyable runs. Hebrew italic here is a synthesized oblique, not a true cursive face.</p>
</div>

<h2>ג. גבולות בין כיוונים</h2>

<div class="case">
  <h3><span class="cid">T-10</span> כתובת URL בפסקה עברית <span class="what">— קישור לחיץ בתוך RTL</span></h3>
  <p class="sample">התיעוד המלא זמין בכתובת
  <a class="ltr" href="https://example.org/docs/rtl?lang=he#selection">https://example.org/docs/rtl?lang=he#selection</a>
  ובעמוד הפנימי <a class="ltr" href="#endnotes">הערות סיום</a> שבסוף המסמך.</p>
  <p class="expect">expected: the URL copies as one unbroken LTR string; both anchors are real clickable link annotations in the PDF.</p>
</div>

<div class="case">
  <h3><span class="cid">T-11</span> מקטע קוד בתוך RTL <span class="what">— בלוק LTR מבודד</span></h3>
  <p class="sample">ההגדרה נעשית בשורה אחת: <code>const dir = "rtl";</code> ולאחריה
  <code>element.setAttribute("dir", dir)</code> — שתי המחרוזות חייבות להישאר בסדר LTR.</p>
</div>

<div class="case">
  <h3><span class="cid">T-12</span> ראשי תיבות לטיניים מודבקים <span class="what">— מקף מחבר בין כיוונים</span></h3>
  <p class="sample big">מערכת ה‑PDF · קובץ ה‑HTML · תקן ה‑Unicode · ממשק ה‑API · שכבת ה‑DOM</p>
  <p class="expect">expected: U+2011/U+2010-style maqaf-joined pairs copy as ה‑PDF etc., with the Latin token intact.</p>
</div>

<h2>ד. מבנים מרובי־שורות</h2>

<p>המקטעים הבאים בודקים את סדר ההעתקה כשהטקסט חוצה תאים, פריטי רשימה, עמודות ועמודים —
המקרים שבהם שכבת בחירה שגויה מחזירה טקסט תקין למראה אך בסדר הפוך.</p>

<ol class="sample">
  <li>פריט ראשון ברשימה ממוספרת — המספר נמצא בצד ימין.</li>
  <li>פריט שני, ובתוכו טוקן לטיני יחיד: <code>selectionchange</code>.</li>
  <li>פריט שלישי, ובתוכו מספר: 3.5% מן המסמכים.</li>
</ol>

<ul class="sample">
  <li>תבליט עם ניקוד: הַבְחִירָה נִשְׁמֶרֶת.</li>
  <li>תבליט עם מספר וסימן: 12 ק״ב.</li>
</ul>

<table>
  <caption>טבלה 1 — מקטעי הבדיקה, ספירת תווים וכיוון בסיס</caption>
  <thead>
    <tr>
      <th>מקטע</th>
      <th>מה נבדק</th>
      <th class="num">תווים</th>
      <th class="num">סימנים משולבים</th>
      <th>כיוון בסיס</th>
    </tr>
  </thead>
  <tbody>
    <tr><th><span class="ltr">T-01</span></th><td>עברית בלבד</td><td class="num">248</td><td class="num">0</td><td>RTL</td></tr>
    <tr><th><span class="ltr">T-02</span></th><td>עברית ואנגלית</td><td class="num">196</td><td class="num">0</td><td>RTL</td></tr>
    <tr><th><span class="ltr">T-05</span></th><td>ניקוד מלא</td><td class="num">41</td><td class="num">19</td><td>RTL</td></tr>
    <tr><th><span class="ltr">T-06</span></th><td>טעמי המקרא</td><td class="num">58</td><td class="num">31</td><td>RTL</td></tr>
    <tr><th><span class="ltr">T-11</span></th><td>מקטע קוד</td><td class="num">112</td><td class="num">0</td><td>LTR בתוך RTL</td></tr>
    <tr><th><span class="ltr">T-13</span></th><td>שתי עמודות</td><td class="num">421</td><td class="num">4</td><td>RTL</td></tr>
  </tbody>
</table>

<div class="case">
  <h3><span class="cid">T-13</span> שתי עמודות <span class="what">— סדר ההעתקה בין עמודה ימנית לשמאלית</span></h3>
  <div class="twocol">
    <p>העמודה הימנית נקראת ראשונה. אם שכבת הבחירה בנויה לפי מיקום גיאומטרי בלבד,
    היא תתחיל דווקא מן העמודה השמאלית, וההעתקה תשלב את שתי העמודות שורה בשורה.</p>
    <p>זו הבדיקה הקשה ביותר בגיליון: הפלט הנכון הוא כל העמודה הימנית, ואחריה
    כל העמודה השמאלית — ולא ערבוב של השתיים.</p>
    <p>העמודה השמאלית נקראת שנייה. היא מכילה מספר, 42, וטוקן לטיני,
    <code>column-count</code>, כדי שניתן יהיה לזהות בקלות היכן היא מתחילה בפלט
    ההעתקה.</p>
    <p>אם המילה ״שנייה״ מופיעה לפני המילה ״ראשונה״ בהדבקה, סדר הקריאה של הקובץ
    שגוי.</p>
  </div>
</div>

<div class="case">
  <h3><span class="cid">T-14</span> פסקה החוצה עמוד <span class="what">— בחירה רציפה בין עמודים</span></h3>
  <p class="sample">הפסקה הזאת נכתבה כדי להתחיל בעמוד אחד ולהסתיים בעמוד הבא, ולכן היא
  ארוכה מן הדרוש. סימון רציף שמתחיל כאן ונגמר אחרי שבירת העמוד חייב להחזיר טקסט אחד
  ורצוף, בלי לחזור לתחילת העמוד ובלי לדלג על השורות שנותרו בתחתית העמוד הקודם.
  בקוראים רבים דווקא כאן נוצרת התקלה: הבחירה נראית נכונה על המסך, אך ההדבקה
  מחזירה את העמוד השני לפני העמוד הראשון, או משמיטה את השורה האחרונה שלפני
  השבירה. כדי שהבדיקה תהיה חד‑משמעית, שתי המילים הבאות מסמנות את הקצוות:
  המילה ״התחלה־כאן״ נמצאת בעמוד הראשון, והמילה ״סיום־שם״ נמצאת בעמוד השני.
  אם ההדבקה מחזירה את שתיהן בסדר הזה, ורק פעם אחת כל אחת, הבדיקה עברה.
  הטקסט שביניהן הוא מלל מילוי שנועד לדחוף את סוף הפסקה מעבר לגבול העמוד:
  שכבת טקסט תקינה שומרת על סדר לוגי גם כשהפריסה הפיזית מפוצלת, וזה בדיוק ההבדל
  בין קובץ שניתן לחפש בו ובין קובץ שנראה נכון אך אינו קריא במכונה.</p>
</div>

<h2>ה. סימני כיוון בלתי נראים</h2>

<div class="case">
  <h3><span class="cid">T-15</span> ‏RLM ו‑LRM <span class="what">— תווי בקרה חסרי רוחב</span></h3>
  <p class="sample">בשורה הזאת שולב תו ‏RLM‏ (U+200F) לפני ואחרי הטוקן הלטיני, ובשורה
  הבאה תו LRM (U+200E) אחרי המספר 7‎ כדי לקבע את הכיוון.</p>
  <p class="expect">expected: invisible marks may be preserved or stripped on copy, but must not reorder the visible text around them.</p>
</div>

<div class="endnotes" id="endnotes">
  <p><strong>הערות סיום</strong></p>
  <ol>
    <li>המסמך אינו נושא מספרי עמודים: תיבות השוליים של <span class="ltr">@page</span> אינן נתמכות במנוע ההדפסה שבו נבנה הקובץ, ולכן לא הוצהר מה שלא ניתן לאמת.</li>
    <li>כיסוי טעמי המקרא במקטע <span class="ltr">T-06</span> תלוי בגופן המותקן במכונה ואינו תכונה של הקובץ.</li>
    <li>כל הנתונים בטבלה 1 הם ספירות מומצאות לצורך הבדיקה ואינם נמדדים מן הקובץ עצמו.</li>
    <li>מקור המסמך: <span class="ltr">hebrew_rtl_selection_test.html</span> · נבנה ל‑<span class="ltr">hebrew_rtl_selection_test.pdf</span>.</li>
  </ol>
</div>

</body>
</html>
Read-only diagnostic probe

Save as probe.mjs. With the investigated checkout's locked dependencies installed and Node 24, run:

node probe.mjs /path/to/scient-desktop /path/to/hebrew_rtl_selection_test.pdf

The probe targets the original fixture's content and rejects known-bad sorting. It is not a production resolver. Its exact-index evidence depends on the original PDF bytes.

// Read-only fixture investigation. This is not a proposed production algorithm.
// Usage: node probe.mjs <scient-desktop checkout> <fixture.pdf>
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { createHash } from "node:crypto";

const [checkout, pdf] = process.argv.slice(2);
assert(checkout && pdf, "Provide checkout and PDF paths");
const { getDocument, version } = await import(pathToFileURL(path.join(checkout,
  "apps/web/node_modules/pdfjs-dist/legacy/build/pdf.mjs")));
const bytes = fs.readFileSync(pdf);
const task = getDocument({ data: new Uint8Array(bytes), useSystemFonts: true });
const contentIds = node => (node.children ?? []).flatMap(child =>
  child.type === "content" ? [child.id] : contentIds(child));
try {
  const doc = await task.promise;
  const blocks = [];
  for (let pageNumber = 1; pageNumber <= doc.numPages; pageNumber++) {
    const page = await doc.getPage(pageNumber);
    // Match TextLayerBuilder's extraction options, including marked content.
    const content = await page.getTextContent({ includeMarkedContent: true, disableNormalization: true });
    const stack = [], items = [];
    for (const [index, item] of content.items.entries()) {
      if (item.type === "beginMarkedContent" || item.type === "beginMarkedContentProps") stack.push(item.id ?? null);
      else if (item.type === "endMarkedContent") stack.pop();
      else items.push({ ...item, index, ids: stack.filter(Boolean) });
    }
    function walk(node) {
      if (!node) return;
      if (node.role === "P") {
        const ids = new Set(contentIds(node));
        blocks.push({ page: pageNumber, ids: [...ids], items: items.filter(item => item.str && item.ids.some(id => ids.has(id))) });
      } else (node.children ?? []).forEach(walk);
    }
    walk(await page.getStructTree());
  }
  const blockFor = text => {
    const matches = blocks.filter(block => block.items.some(item => item.str.includes(text)));
    assert.equal(matches.length, 1, `Expected one paragraph for ${text}`);
    return matches[0];
  };
  // Deliberately naive comparator, evaluated only to establish counterexamples.
  // The fixed tolerance is appropriate to this fixture probe, not general PDFs.
  const naiveRTL = block => {
    const rows = [];
    for (const item of block.items) {
      let row = rows.find(row => Math.abs(row.y - item.transform[5]) < 0.5);
      if (!row) rows.push(row = { y: item.transform[5], items: [] });
      row.items.push(item);
    }
    return rows.sort((a, b) => b.y - a.y).map(row => row.items.slice()
      .sort((a, b) => b.transform[4] - a.transform[4]).map(item => item.str).join("")).join("\n");
  };
  const t01 = blockFor("מסמך זה נבנה");
  const first = t01.items.find(item => item.str.startsWith("מסמך זה נבנה"));
  const english = t01.items.find(item => item.str === "PDF");
  const second = t01.items.find(item => item.str.startsWith("נכתבה כך"));
  assert(english.index < first.index && first.index < second.index,
    "Cross-line native DOM range excludes the earlier English text item");
  assert(naiveRTL(t01).startsWith("מסמך זה נבנה כדי לבחון את שכבת הבחירה של קורא ה-PDF בטקסט עברי."));
  const t02 = blockFor("מנוע הרינדור");
  assert(t02.items.map(item => item.str).join("").includes("pdf.js"));
  assert(naiveRTL(t02).includes("js.pdf"));
  const t04 = blockFor("הפונקציה");
  assert(naiveRTL(t04).includes(")parseInt("));
  const t11 = blockFor("ההגדרה נעשית");
  assert(t11.items.map(item => item.str).join("").includes('const dir = "rtl";'));
  assert(naiveRTL(t11).includes('"rtl"; = dir const'));
  const t14 = blockFor("הפסקה הזאת נכתבה");
  assert(t14.items.some(item => item.str.includes("קובץ שניתן לחפש")));
  console.log(JSON.stringify({
    pdf, sha256: createHash("sha256").update(bytes).digest("hex"), pdfjs: version,
    result: "pass",
    t01: { page: t01.page, items: t01.items.map(item => ({ index: item.index, text: item.str, dir: item.dir })) },
    counterexamples: [t02, t04, t11].map(block => ({ page: block.page, ids: block.ids, naiveRTL: naiveRTL(block) })),
    t14: { page: t14.page, note: "Entire paragraph occurs on this page; not a cross-page fixture" },
    limitation: "Extraction and ordering assertions only. No native pointer, keyboard, clipboard, or geometry test."
  }, null, 2));
} finally {
  await task.destroy();
}

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething is broken or behaving incorrectly.needs-triageIssue needs maintainer review and initial categorization.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions