PDF export: All Caps text extracts correctly (#1473) (#1509)

* #1473: WIP

* Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1473: list labels and field results in caps extract as shown

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Dmitrii Khlapov
2026-10-14 14:27:23 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Dmitrii Khlapov Brandon Thomas
parent 37d41150e6
commit 52e56f8d76
5 changed files with 93 additions and 6 deletions
+3 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: All Caps text in PDFs, #1473; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
@@ -152,6 +152,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-13 | Interface size means what it says (#822): a chosen size is absolute, as in PhotoCraft and PdfCraft, so "100%" is one screen pixel per point even where the system reports 200 % (a bare window manager on X11, where winit guesses the scale from the monitor's DPI). The new default, Auto, keeps the system's display scale and its menu entry shows the size it draws at; sizes run 75–300 %. A saved size from before stays the same if it was the default (now Auto) and becomes absolute otherwise |
| 2026-10-13 | Copying an equation also puts MathML in HTML on the clipboard beside the linear text like `y=2x_s` (#816; Word/LibreOffice paste not yet verified; OMML is #1297). HTML export writes equations as MathML too, and opening or pasting HTML with MathML gives equations back |
| 2026-10-13 | The Equation tab's galleries, structure buttons and symbols keep the theme's colours on Windows: their pictures share a few textures written in place (no wipe of pictures already on screen, room for the built-ins at high display scales), instead of hundreds of small textures freed in bulk, which on DirectX 12 showed inverted, red or teal tiles (#1002) |
| 2026-10-14 | Text copied out of an exported PDF reads as shown (#1473): All Caps and Small Caps text comes out in capitals, as from Word's PDFs, and plain text after it keeps its own case (before, a capital first drawn for a lowercase letter made later capitals extract lowercase) |
| 2026-10-14 | ODT files open with their sections (#1379): a paragraph that switches to another page style starts a new section on a new page, with that page's size, orientation, margins and columns and, when the file asks, page numbering restarting; a two-column `text:section` becomes a continuous two-column section. Before, the whole document was one portrait section, so landscape pages, columns and restarts were lost on open and on save to DOCX |
| 2026-10-14 | Hebrew and Arabic in exported PDFs copy and search as whole words (#1383): right-to-left glyphs are written left to right as they appear, the order Chrome and Edge (PDFium) and poppler read text back from (checked by hand), instead of in reading order, which they reversed (`שלום` came back as `םולש`, Arabic in pieces). Lam-alef stays whole and vowel marks follow their letters, though Chrome and poppler can still break a fully vowelled word with spaces. The page looks the same. Readers that take glyphs strictly in file order get only plain Hebrew runs right, through `/ReversedChars`; screen readers are unverified |
| 2026-10-14 | HTML import (#1381): header cells (`<th>`) are bold unless their own CSS says otherwise, and `text-align` on a cell, its row, row group or table aligns the cell's paragraphs (a paragraph's own alignment wins), so `wordcraft-cli convert page.html out.docx` writes the bold header and `w:jc` right-aligned cells. HTML, Markdown and plain text, which give no page size, open on the region's paper (A4 in Italy, Letter in the US) like a new blank document, in the app and in `wordcraft-cli`; documents with their own page size keep it |
@@ -327,6 +328,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-13 | trivial | Progress log: the caret contrasts with its background (#299) |
| 2026-10-13 | trivial | Progress log: clicks on ribbon chrome keep typing in the document (#1093) |
| 2026-10-13 | trivial | Progress log: dragged shapes wrap in their new column (#884) |
| 2026-10-14 | trivial | Progress log: All Caps and Small Caps text extracts from PDFs as shown (#1473) |
| 2026-10-14 | trivial | Progress log: ODT sections on import (#1379) |
| 2026-10-14 | trivial | Progress log: right-to-left text in exported PDFs extracts as whole words (#1383) |
| 2026-10-14 | trivial | Progress log: HTML header cells, cell alignment and region paper for opened HTML/Markdown/text (#1381) |
+11 -1
View File
@@ -763,13 +763,23 @@ fn lines(
if let Ok(i) = pl.shown.binary_search_by_key(&k, |(i, _, _)| *i)
&& let Some((_, s, r)) = pl.shown.get(i)
{
text.push_str(s);
// All Caps field results read as the capitals drawn (#1473). `r` holds
// byte ranges into `s`, so a character whose capital has another UTF-8
// length keeps its own form and every range still lands where it did.
text.extend(s.chars().map(|ch| {
let up = crate::para::caps_char(&st.rc, ch);
if up.len_utf8() == ch.len_utf8() { up } else { ch }
}));
shown = Some(r);
} else if let Some(p) = para {
let t = p.text.get(c.start..c.end).unwrap_or("");
// Symbol-font characters (Wingdings…) read as the Unicode symbols drawn.
if wordcraft_doc::symbol_fonts::is_symbol_font(&st.rc.font) {
text.push_str(&wordcraft_doc::symbol_fonts::map_text(&st.rc.font, t));
} else if st.rc.caps || st.rc.small_caps {
// All Caps and Small Caps read as the capitals drawn (#1473), mapped
// as the shaper maps them, so a glyph never stands for two cases.
text.extend(t.chars().map(|ch| crate::para::caps_char(&st.rc, ch)));
} else {
text.push_str(t);
}
+15 -2
View File
@@ -514,6 +514,18 @@ impl SegKey {
}
}
/// The capital All Caps and Small Caps draw for `c`: one character in, one out (ß draws as S).
fn upper_char(c: char) -> char {
c.to_uppercase().next().unwrap_or(c)
}
/// The character drawn for `c` in a run formatted `rc`: All Caps capitalises every letter,
/// Small Caps the lowercase ones (as the shaper draws them). The PDF text layer uses it so
/// the text read back is the text shown (#1473).
pub(crate) fn caps_char(rc: &ResolvedChar, c: char) -> char {
if rc.caps || (rc.small_caps && c.is_lowercase()) { upper_char(c) } else { c }
}
/// Combining marks and joiners (bidi classes NSM, BN: harakat, ZWNJ, ZWJ) belong to the
/// character before them: same font, same shaping run, so Arabic joining isn't broken.
fn is_mark(c: char) -> bool {
@@ -817,7 +829,7 @@ impl<'a> Builder<'a> {
if d != c {
d
} else if upper {
c.to_uppercase().next().unwrap_or(c)
upper_char(c)
} else {
c
}
@@ -1467,7 +1479,8 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
for g in b.glyphs.get(c.g0 as usize..c.g1 as usize).into_iter().flatten() {
glyphs.push(Glyph { dx: g.dx + x, ..*g });
}
let chars = shown(text.get(c.start..c.end).unwrap_or(""));
// A label in an All Caps paragraph reads as the capitals drawn (#1473).
let chars: String = shown(text.get(c.start..c.end).unwrap_or("")).chars().map(|ch| caps_char(&rc, ch)).collect();
match runs.last_mut() {
Some(r) if r.style == c.style => {
r.glyphs.end = glyphs.len();
+61
View File
@@ -1024,6 +1024,67 @@ fn internal_links_export_in_linear_time() {
assert!(ratio < 9.0, "4x the paragraphs and links took {ratio:.1}x as long (quadratic would be ~16x)");
}
#[test]
fn all_caps_and_small_caps_text_extracts_as_displayed() {
// #1473: All Caps / Small Caps text is drawn with capital glyphs, so it extracts in capitals
// (as Word's PDFs do), and later text without caps keeps its own case: a capital glyph
// first drawn for a lowercase letter must not make every later capital read as lowercase.
let mut d = Document::new();
d.styles.upsert(wordcraft_doc::Style {
id: "CapsPara".into(),
name: "Caps Para".into(),
kind: wordcraft_doc::StyleKind::Paragraph,
chr: CharProps { caps: Some(true), ..Default::default() },
..Default::default()
});
let caps = CharProps { caps: Some(true), ..Default::default() };
let small = CharProps { small_caps: Some(true), ..Default::default() };
d.body = vec![
para_block(Paragraph::with_text("Employment Period. Your employment will begin on the date below.", CharProps::default()).styled("CapsPara")),
para_block(Paragraph::with_text("Direct caps on the run: Mixed Case Words.", caps.clone())),
para_block(Paragraph::with_text("Plain text, no caps: Employment Period.", CharProps::default())),
para_block(Paragraph::with_text("Small caps line: Quiet Words.", small)),
para_block(Paragraph::with_text("Straße", caps)),
para_block(Paragraph::with_text("Plain again: Straße, Quiet Period.", CharProps::default())),
];
let text = extract_text(&export(&d, &PdfOptions::default()).unwrap()).concat();
let text = squash(&text);
for line in [
"EMPLOYMENT PERIOD. YOUR EMPLOYMENT WILL BEGIN ON THE DATE BELOW.",
"DIRECT CAPS ON THE RUN: MIXED CASE WORDS.",
"Plain text, no caps: Employment Period.",
"SMALL CAPS LINE: QUIET WORDS.",
"Plain again: Straße, Quiet Period.",
] {
assert!(text.contains(line), "missing {line:?} in {text:?}");
}
// An All Caps ß is drawn today as one S glyph, so it reads as that S: the text layer follows
// the glyph drawn. Drawing it as "SS", as Word does, is a separate display issue.
assert!(text.contains("STRASE"), "{text:?}");
}
#[test]
fn caps_list_labels_extract_as_displayed() {
// #1473: a lowercase-letter list label in an All Caps paragraph is drawn with capital glyphs;
// it reads as those capitals, and a later plain capital A still reads as "A".
let mut d = Document::new();
d.styles.upsert(wordcraft_doc::Style {
id: "CapsPara".into(),
name: "Caps Para".into(),
kind: wordcraft_doc::StyleKind::Paragraph,
chr: CharProps { caps: Some(true), ..Default::default() },
..Default::default()
});
let list = d.numbering.add_list(ListKind::Numbered);
let mut item = Paragraph::with_text("item", CharProps::default()).styled("CapsPara");
item.props.numbering = Some(NumRef { num: list, level: 1 });
d.body = vec![para_block(item), para_block(Paragraph::with_text("Plain Apple.", CharProps::default()))];
let text = squash(&extract_text(&export(&d, &PdfOptions::default()).unwrap()).concat());
assert!(text.contains("A."), "the label reads as the capital drawn: {text:?}");
assert!(!text.contains("a."), "no lowercase label: {text:?}");
assert!(text.contains("Plain Apple."), "a plain capital A keeps its case: {text:?}");
}
/// The reporter's four paragraphs from #1383 (Hebrew, Arabic and mixed right-to-left paragraphs,
/// and a Latin one), then right-to-left words with a lam-alef ligature and with vowel marks.
fn rtl_sample() -> Document {
+3 -2
View File
@@ -1,6 +1,6 @@
# File-format parity with Microsoft Word
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** trivial (ODT sections on import, #1379; previously trivial: PDF export: Hebrew and Arabic text extracts as whole words, #1383; previously trivial: HTML header cells bold, cell `text-align` kept, and HTML/Markdown/text open on the region's paper, #1381; previously trivial: `.doc` table cell shading and the extra column, #1380; previously trivial: `.doc` tables from Word 2000+: cell shading, no extra column, merged rows, #1380; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: equations as MathML in HTML export, copy and paste, #816; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** trivial (All Caps and Small Caps text extracts from PDFs as the capitals shown, #1473; previously trivial: ODT sections on import, #1379; previously trivial: PDF export: Hebrew and Arabic text extracts as whole words, #1383; previously trivial: HTML header cells bold, cell `text-align` kept, and HTML/Markdown/text open on the region's paper, #1381; previously trivial: `.doc` table cell shading and the extra column, #1380; previously trivial: `.doc` tables from Word 2000+: cell shading, no extra column, merged rows, #1380; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: equations as MathML in HTML export, copy and paste, #816; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the
share of a typical real-world file's content and formatting that survives (read: shows correctly;
@@ -22,7 +22,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅
| `.docx` Word Document (ECMA-376 Transitional) | 70% | R/W | ✅ | ✅ | ~65% | 70–110 | `crates/docx/tests` (roundtrip 34, fixtures 29, malformed 8, bidi 5, citations 5); synthetic fixtures only | Word opens our files. Untested on a real-world corpus. Details below |
| `.docx` Strict Open XML | 1% | R/W | ✅ (namespace mapping in `xml.rs`) | ❌ | ~60% | 2–4 | one fixture | Writing Strict is rare |
| `.docm` / `.dotx` / `.dotm` | 3% | R/W | ✅ | ✅ | ~65% | incl. above | `macro_packages.rs` | Macros and signatures kept on save (#172); macros never run |
| PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133); Hebrew and Arabic copy and search as whole words (right-to-left glyphs written in visual order, #1383), links, outline, tagging. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. Horizontal and vertical lines inside groups are drawn (#1474). No PDF/A option, no "best for printing/online" choice |
| PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133); Hebrew and Arabic copy and search as whole words (right-to-left glyphs written in visual order, #1383); All Caps and Small Caps text extracts as the capitals shown, as from Word's PDFs, and never changes the case of other text (#1473), links, outline, tagging. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. Horizontal and vertical lines inside groups are drawn (#1474). No PDF/A option, no "best for printing/online" choice |
| `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~60% | 25–40 | `crates/docbin/src/tests.rs`, `tests/drawing.rs` (56) | Spec-based reader ([MS-DOC]): text, formatting, styles, sections, headers/footers, tables, lists, notes, fields, bookmarks; inline and floating pictures (PNG, JPEG, DIB, TIFF, EMF, WMF; Macintosh PICT as a sized placeholder) with crop, alt text, position and wrap; basic shapes, lines, groups and text boxes ([MS-ODRAW]). Table cell shading from every SHD record (`sprmTDefTableShd`, `sprmTDefTableShdRaw` and their 2nd/3rd ranges, with the Word 97 `Shd80` as fallback; a clear pattern shows its background colour); the row-end mark is not a cell, and each table has its own grid and properties (#1380). Horizontally merged rows stored as fewer, wider cells come with #1123. Freeform shapes, z-order and Word 6/95 or encrypted files are dropped or refused. No writer |
| PDF open (PDF Reflow, Word for Windows) | 1% | R | ❌ | — | 0% | 30–50 | — | Converting a PDF into an editable document. A sibling app (PdfCraft) may supply the parser |
| `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs`, `crates/formats/src/odt.rs` unit tests, `crates/engine/tests/odt_sections.rs` | Styles, lists (#87), tables, pictures, headers/footers, page breaks (#86), page size, orientation and margins (#810), sections: one per master-page change on a paragraph or table (next page, with its own page size, orientation, margins, columns and page-numbering restart) and per columned `text:section` (continuous, with its columns and separator line) (#1379). No footnotes, comments, tracked changes, fields or RTL |
@@ -169,6 +169,7 @@ raise readiness estimates. No Word-derived files are fixtures.
| 2026-10-13 | trivial | Remove personal information / Remove date and time settings kept and honoured on save: anonymous annotation authors, no creator or last-modified-by, no annotation dates (#1062) |
| 2026-10-12 | trivial | Direct run-level `w:contentPart` InkML keeps its relationship, content type and bytes through DOCX edits and repeated saves; nested group/canvas ink and native rendering remain missing |
| 2026-10-13 | trivial | HTML export writes equations as MathML instead of linear text, and HTML import (open and paste) reads `<math>` back into equations; a copy holding an equation puts that HTML on the system clipboard: MathML in HTML on the clipboard (Word/LibreOffice paste not yet verified; OMML is #1297) (#816) |
| 2026-10-14 | trivial | PDF export: All Caps and Small Caps text extracts in capitals, as displayed, and no longer turns later capitals in uncapitalised text lowercase (#1473) |
| 2026-10-14 | trivial | ODT import reads sections: a paragraph naming another master page starts a new-page section with that page's size, orientation, margins and columns, and its `style:page-number` restart; a columned `text:section` is a continuous section with its columns (#1379) |
| 2026-10-14 | trivial | PDF export writes right-to-left runs' glyphs in visual order, so Chrome, Edge (PDFium) and poppler extract Hebrew and Arabic as whole words (vowelled words keep their letters in order but may extract with spaces inside), and Ctrl+F finds them (#1383). `/ReversedChars` (for strict file-order readers) covers plain runs and vowelled runs whose marks split into their own glyphs, not runs with ligatures or mixed directions |
| 2026-10-14 | trivial | HTML: `<th>` bold, cell `text-align` (own or inherited) reaches the cell's paragraphs; HTML, Markdown and plain text open on the region's paper, in the app and `wordcraft-cli` (#1381) |