Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ rows are the enforced release-mode bounds plus one dated observation.

| Measurement | Value | Version | Platform | Build mode | Input | Command | Statistic | Measured on |
|---|---|---|---|---|---|---|---|---|
| Crates.io archive: rdocx | 1,092,256 compressed bytes, 6,498,484 member bytes, 36 members | 0.14.0 | macOS 26.6.2, Apple M5 Max, arm64 | `cargo package --locked --no-verify` | Tracked `rdocx` package inventory | `python3 scripts/readme_doctests.py --record-measurements` | gzip archive bytes, tar member bytes, tar member count | 2026-09-26 |
| Crates.io archive: rdocx | 1,093,062 compressed bytes, 6,500,922 member bytes, 36 members | 0.14.0 | macOS 26.6.2, Apple M5 Max, arm64 | `cargo package --locked --no-verify` | Tracked `rdocx` package inventory | `python3 scripts/readme_doctests.py --record-measurements` | gzip archive bytes, tar member bytes, tar member count | 2026-09-27 |
| Large-document layout throughput | minimum 250 pages/s, observed 31,019.1 pages/s | rdocx 0.14.0 | macOS 26.6.2, Apple M5 Max, arm64 | release, one test thread | 1,000 one-page paragraphs with deterministic fonts | `cargo test -p rdocx --test regression_test --release a_thousand_page_document_paginates_and_renders_within_the_declared_limits -- --ignored --exact --nocapture --test-threads=1` | pages per wall-clock second | 2026-09-19 |
| Large-document layout peak allocation | maximum 64 MiB, observed 29.03 MiB | rdocx 0.14.0 | macOS 26.6.2, Apple M5 Max, arm64 | release, one test thread | 1,000 one-page paragraphs with deterministic fonts | `cargo test -p rdocx --test regression_test --release a_thousand_page_document_paginates_and_renders_within_the_declared_limits -- --ignored --exact --nocapture --test-threads=1` | peak live allocation | 2026-09-19 |
| Large-document PDF throughput | minimum 1,000 pages/s, observed 60,058.0 pages/s | rdocx 0.14.0 | macOS 26.6.2, Apple M5 Max, arm64 | release, one test thread | 1,000 deterministic layout pages | `cargo test -p rdocx --test regression_test --release a_thousand_page_document_paginates_and_renders_within_the_declared_limits -- --ignored --exact --nocapture --test-threads=1` | pages per wall-clock second | 2026-09-19 |
Expand Down
2 changes: 1 addition & 1 deletion crates/oxml-pdf/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ rows are the enforced release-mode bounds plus one dated observation.

| Measurement | Value | Version | Platform | Build mode | Input | Command | Statistic | Measured on |
|---|---|---|---|---|---|---|---|---|
| Crates.io archive: oxml-pdf | 66,015 compressed bytes, 304,432 member bytes, 14 members | 0.12.1 | macOS 26.6.2, Apple M5 Max, arm64 | `cargo package --locked --no-verify` | Tracked `oxml-pdf` package inventory | `python3 scripts/readme_doctests.py --record-measurements` | gzip archive bytes, tar member bytes, tar member count | 2026-09-19 |
| Crates.io archive: oxml-pdf | 72,802 compressed bytes, 332,454 member bytes, 14 members | 0.12.1 | macOS 26.6.2, Apple M5 Max, arm64 | `cargo package --locked --no-verify` | Tracked `oxml-pdf` package inventory | `python3 scripts/readme_doctests.py --record-measurements` | gzip archive bytes, tar member bytes, tar member count | 2026-09-27 |
| Large-document layout throughput | minimum 250 pages/s, observed 31,019.1 pages/s | rdocx 0.14.0 | macOS 26.6.2, Apple M5 Max, arm64 | release, one test thread | 1,000 one-page paragraphs with deterministic fonts | `cargo test -p rdocx --test regression_test --release a_thousand_page_document_paginates_and_renders_within_the_declared_limits -- --ignored --exact --nocapture --test-threads=1` | pages per wall-clock second | 2026-09-19 |
| Large-document layout peak allocation | maximum 64 MiB, observed 29.03 MiB | rdocx 0.14.0 | macOS 26.6.2, Apple M5 Max, arm64 | release, one test thread | 1,000 one-page paragraphs with deterministic fonts | `cargo test -p rdocx --test regression_test --release a_thousand_page_document_paginates_and_renders_within_the_declared_limits -- --ignored --exact --nocapture --test-threads=1` | peak live allocation | 2026-09-19 |
| Large-document PDF throughput | minimum 1,000 pages/s, observed 60,058.0 pages/s | rdocx 0.14.0 | macOS 26.6.2, Apple M5 Max, arm64 | release, one test thread | 1,000 deterministic layout pages | `cargo test -p rdocx --test regression_test --release a_thousand_page_document_paginates_and_renders_within_the_declared_limits -- --ignored --exact --nocapture --test-threads=1` | pages per wall-clock second | 2026-09-19 |
Expand Down
702 changes: 682 additions & 20 deletions crates/oxml-pdf/src/font.rs

Large diffs are not rendered by default.

66 changes: 66 additions & 0 deletions crates/rdocx/tests/integration_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13060,6 +13060,72 @@ mod header_footer_pdf {
);
}

/// GitHub issue #171. Carlito, the bundled Calibri, joins ti, fi, ft, ffi
/// and more into one glyph. The PDF text layer paired glyphs with
/// characters by index, which garbled every Calibri paragraph.
#[test]
fn ligatures_keep_their_characters_in_pdf_text_of_every_bundled_family() {
const TEXT: &str = "Location Rating Action fifteen office Observation";
let mut document = Document::new();
let mut expected = Vec::new();
for font in [
"Calibri",
"Arial",
"Cambria",
"Times New Roman",
"Courier New",
] {
for bold in [false, true] {
let weight = if bold { "bold" } else { "regular" };
let line = format!("{font} {weight}: {TEXT}");
document
.add_paragraph("")
.add_run(&line)
.font(font)
.size(11.0)
.bold(bold);
expected.push(line);
}
}
let pdf = document.to_pdf_deterministic().unwrap();

let page_text = pdf_page_text(&pdf).concat();
for line in &expected {
assert!(
page_text.contains(line.as_str()),
"{line:?} in {page_text:?}"
);
}

let path = std::env::temp_dir().join(format!(
"rdocx-171-ligatures-{}-{:?}.pdf",
std::process::id(),
std::thread::current().id()
));
std::fs::write(&path, &pdf).unwrap();
let extracted = std::process::Command::new("pdftotext")
.arg(&path)
.arg("-")
.output();
std::fs::remove_file(&path).unwrap();
match extracted {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
eprintln!("#171 pdftotext check skipped because pdftotext is absent");
}
extracted => {
let extracted = extracted.unwrap();
assert!(extracted.status.success());
let lines = String::from_utf8(extracted.stdout).unwrap();
let lines = lines
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect::<Vec<_>>();
assert_eq!(lines, expected);
}
}
}

#[test]
fn word_semantics_reach_owned_multi_page_pdf_structure() {
fn count_bytes(haystack: &[u8], needle: &[u8]) -> usize {
Expand Down
2 changes: 1 addition & 1 deletion crates/rpptx/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ presentation, notes, handout, PDF, and animation outputs.

| Measurement | Value | Version | Platform | Build mode | Input | Command | Statistic | Measured on |
|---|---|---|---|---|---|---|---|---|
| Crates.io archive: rpptx | 407,658 compressed bytes, 2,122,094 member bytes, 16 members | 0.12.1 | macOS 26.6.2, Apple M5 Max, arm64 | `cargo package --locked --no-verify` | Tracked `rpptx` package inventory | `python3 scripts/readme_doctests.py --record-measurements` | gzip archive bytes, tar member bytes, tar member count | 2026-09-26 |
| Crates.io archive: rpptx | 408,469 compressed bytes, 2,124,754 member bytes, 16 members | 0.12.1 | macOS 26.6.2, Apple M5 Max, arm64 | `cargo package --locked --no-verify` | Tracked `rpptx` package inventory | `python3 scripts/readme_doctests.py --record-measurements` | gzip archive bytes, tar member bytes, tar member count | 2026-09-27 |

## Use it when

Expand Down
65 changes: 65 additions & 0 deletions crates/rpptx/tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,71 @@ fn pdf_import_differential_rejects_geometry_text_link_and_pixel_perturbations()
));
}

/// GitHub issue #171. Without an inherited `rtl` a paragraph draws its Latin
/// text as plain runs, which carry no shaping clusters, so a Carlito ligature
/// reaches the PDF text layer only through the ToUnicode map.
#[test]
#[cfg(feature = "render")]
fn plain_run_ligatures_keep_their_characters_in_pdf_text() {
const TEXT: &str = "Location Rating Action fifteen office Observation";
let mut source = Presentation::new().unwrap();
source.add_slide(0).unwrap();
source
.slide_mut(0)
.unwrap()
.add_textbox(Emu(457_200), Emu(914_400), Emu(8_229_600), Emu(914_400))
.unwrap()
.set_text(TEXT)
.unwrap();
let mut package = open_opc(&source.to_bytes().unwrap(), "#171 ligature deck");
for part in [
"/ppt/presentation.xml",
"/ppt/slideMasters/slideMaster1.xml",
] {
let xml = String::from_utf8(package.get_part(part).unwrap().to_vec()).unwrap();
assert!(xml.contains(r#" rtl="0""#), "{part} sets the direction");
package.set_part(part, xml.replace(r#" rtl="0""#, "").into_bytes());
}
let presentation = Presentation::from_bytes(&package_bytes(package)).unwrap();

let (_, layout) = presentation.render_deterministic().unwrap();
let mut plain_text = String::new();
walk(&layout.pages[0].elements, &mut |element, _| match element {
PositionedElement::Text(run) => plain_text.push_str(&run.text),
PositionedElement::MultilingualText(run) => {
panic!("{:?} took the path with clusters", run.logical_text)
}
_ => {}
});
assert_eq!(plain_text.trim_end(), TEXT);

// lopdf decodes each drawn run through the ToUnicode map on its own line.
let pdf = presentation.to_pdf_deterministic().unwrap();
let decoded = lopdf::Document::load_mem(&pdf)
.unwrap()
.extract_text(&[1])
.unwrap();
assert_eq!(
decoded.split_whitespace().collect::<Vec<_>>().join(" "),
TEXT
);

let path = std::env::temp_dir().join(format!("rpptx-171-ligatures-{}.pdf", std::process::id()));
fs::write(&path, &pdf).unwrap();
let extracted = Command::new("pdftotext").arg(&path).arg("-").output();
fs::remove_file(&path).unwrap();
match extracted {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
eprintln!("#171 pdftotext check skipped because pdftotext is absent");
}
extracted => {
let extracted = extracted.unwrap();
assert!(extracted.status.success());
assert_eq!(String::from_utf8(extracted.stdout).unwrap().trim(), TEXT);
}
}
}

#[test]
#[cfg(feature = "default-template")]
#[ignore = "requires Google Chrome 152.0.7977.65"]
Expand Down
25 changes: 25 additions & 0 deletions docs/hld/08-rendering-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,31 @@ with no visible difference and no failing test.
Two writes of one document cannot detect that, since they reuse the same map
instances. The regression builds two documents and compares their bytes.

**The text layer follows what each glyph draws.** A `GlyphRun` carries its
glyphs and its text but not the shaper's clusters, and a ligature draws several
characters with one glyph, so pairing them by index shifts every later glyph.
The writer reads the pairing from the font instead. A glyph draws the next
character when the cmap gives it that character, and the next few when a GSUB
ligature joins their glyphs into it, so the Carlito `ti` or `ffi` of a Calibri
document extracts as its characters. Glyphs the font explains neither way share
the characters up to the next glyph it does explain, looked for within 16
glyphs and characters. When there is none, an unexplained glyph takes one
character by position, which keeps the pairing linear in a run the font
explains nowhere. A plain run has no `ActualText`, so there a glyph that draws
none of its group's characters by itself adds no ToUnicode text rather than
repeat a character another glyph carries, and neither does a glyph left after
the run's last character. A rich run's clusters give their characters to their
glyphs the same way, so no character of a cluster is lost. In a rich run, a
glyph that draws none of them by itself, such as the dots an Arabic font draws
apart from their letter, repeats the text of its cluster, which the run's
`ActualText` covers, so every glyph of a rich run maps to Unicode. The
ToUnicode CMap holds one entry per glyph for the whole font, so a glyph that
draws different text in different places keeps its strongest pairing, a GSUB
ligature first, then the cmap, then an inferred pairing. The ligature comes
first because one glyph can be both, as the Carlito `fi` ligature and U+FB01
are, and one literal U+FB01 must not turn every `fi` of the document into
U+FB01.

When `LayoutResult::structure` is present, the writer emits deterministic
`BDC` and `EMC` pairs with page-local MCIDs, `/StructParents`, one parent number
tree, `/StructTreeRoot`, `/MarkInfo`, an undetermined `/Lang`, and accessible
Expand Down
30 changes: 15 additions & 15 deletions scripts/hash_baseline.json
Original file line number Diff line number Diff line change
@@ -1,54 +1,54 @@
{
"entries": {
"contract:page1.png": "4c4249d4efe7ea72590dfdeb3773d316da626f7189c7fc0606566f4bfb11a92f",
"contract:pdf/bytes": "0b2aa4c1031e4bc5a1a59a77b5a1a0a48ec3d933c5c89306971d7a4a30a78c89",
"contract:pdf/bytes": "2bb2a4344037ee8221cc0f330e1574551236d81e46f4999d0cccca658dd2b355",
"contract:pdf/pages": "9b3ff34d7683336e1fea5ad3c42d733dc443ead52d645b79d30f531ff93e00aa",
"contract:pdf/resources": "1acfd604144a00109958c756bb0cb8894089040954cfbd573ceab9b40823d5fc",
"contract:pdf/resources": "1d23fd81e7aaa8ef154223e1574675613bcb428234ef1ded21ffc29ac2aedaf5",
"contract:word/document.xml": "a6cebb1e5df54f1191494d9f54600494b7709e09b37402fef42eae06cd48c2fa",
"contract:word/numbering.xml": "01d6cb0aa0ecc30b3d2a77c4df062b1c727d70ff9cbc0824910f049daf878a26",
"contract:word/styles.xml": "0dc0b047b6019b798b83bfe4d66eb91d14b03caea3f6bdb0934d48fbb863fba4",
"feature_showcase:page1.png": "6e88403fe63a789fd2ce6803029f67fcd45e00f2d13b9c13d5201a99eda0ada9",
"feature_showcase:pdf/bytes": "bf8a0c5bbd4ba70aba19b39e1a19bc1c78d19a12ac6f6324964628df5f78d410",
"feature_showcase:pdf/bytes": "38be2148f2e50ec969e0d652e20d6bf4927d90a24cc46e70ca801343e3014d4f",
"feature_showcase:pdf/pages": "562b6508a015c227d598b408dfafaf2056cbba4e617e323b20ee39168ff64224",
"feature_showcase:pdf/resources": "e624cef80cac1f99ba398b0b0f8fc7986c0389cf12b6599f2a9bb187749bb797",
"feature_showcase:pdf/resources": "3573cc6e7183b5e912bfde97f11edea4ec44bb40e1b8f066eeb9b4e5e7726d62",
"feature_showcase:word/document.xml": "7bfdbc2740871b1b38314dc95b353237d18e49b56ea64fa7a4c6499e4d21c8d3",
"feature_showcase:word/numbering.xml": "2aa0599486d98573be0b7febb04ec63c08d3dfc5341b0f722b93cae113803bbc",
"feature_showcase:word/styles.xml": "b815acd04cc89189d5b2b0caf822435d941c8cc70077f34932e4a2b3ce1b6595",
"invoice:page1.png": "a209c0493bc90aae717e930b4466739212aaa4b646d1123a8a21c5e75b5c9644",
"invoice:pdf/bytes": "8e53fa760135722ab1ddd1593ee6fab2405068efec023e8c281438292fcdba03",
"invoice:pdf/bytes": "f2062c5bf095469f63d8c3b834150b8f7e1ef30ddd3a2fd1e77728f1928ccb87",
"invoice:pdf/pages": "aadd561eb1aae1ff10748513e7cbdad0d9bdc6207baa0a286f934528bc3428a2",
"invoice:pdf/resources": "01d97372b5ad8c9fb06649d519f14311eb0428572af93c330c5fb393e74e323a",
"invoice:pdf/resources": "e48c1d7946b7e918931371e483bf48de7906358fd60213be521d49df2b83773c",
"invoice:word/document.xml": "848426495d2a6f94e1c1514959938de96a13f9d2997d5ebfdb15ab0d18b7dbe7",
"invoice:word/numbering.xml": null,
"invoice:word/styles.xml": "0dc0b047b6019b798b83bfe4d66eb91d14b03caea3f6bdb0934d48fbb863fba4",
"letter:page1.png": "79b23d965cd43bbdbb3cf2128df8f51b5d2d53d4a8934aaa34ed00b2aef8821e",
"letter:pdf/bytes": "634bf0bc38b2fe8ef5b55feadc3edef8fece56837effac278e4851a910055611",
"letter:pdf/bytes": "3893bd426a5e55eb13f0a4f581cc65da09b83a095605d836d1239a16f37a9667",
"letter:pdf/pages": "92a27b62d2e6f40cee2eb742dba240e2b49c002fa2b351e60c7bd6952cd289ba",
"letter:pdf/resources": "0393c6de085093a2af078e708a4092bc1a55137616fd6fac30e01fdb32f732ee",
"letter:pdf/resources": "c63ba60143ff50ba08f981ec6eddde770d71205442441df18902a2c1857f591b",
"letter:word/document.xml": "568e43d5433e4bd80d7c8a747a1c9b8b8f295dfb59464570fa37aa32668369a9",
"letter:word/numbering.xml": "c6511604704117eb00ad2faffb9173e4f48d22f62557ca72a51d60b7907c8058",
"letter:word/styles.xml": "0dc0b047b6019b798b83bfe4d66eb91d14b03caea3f6bdb0934d48fbb863fba4",
"proposal:page1.png": "d92257f65b7f149b68f39ace59aec2613924112fe0dc1584ac9dbbd8d3beda34",
"proposal:pdf/bytes": "ccd3d55fea503f170955b5d5c427c873766c152bc3d37fe782fa167438bf92b0",
"proposal:pdf/bytes": "2f6d1bc76995ed9469dedcf022f1b95684ef6b9cb9a47da0ba5a83ed87882967",
"proposal:pdf/pages": "1f9b047bfd0e39ff0e46d1fb6fc6cf04fcabac26a3f3735f70352d7b596eaefb",
"proposal:pdf/resources": "7e56e693e3e1fbaf829b2893172b76c7e63345b4b81d37ab5c76a35febc675be",
"proposal:pdf/resources": "775e228634aad44a60949777692e6381bdd3f5d754764c3dae276e241ea81569",
"proposal:word/document.xml": "7235579097314e5cc8b07822884ebfadd9927b3ae34820a2673ba63097c5e02e",
"proposal:word/numbering.xml": "061e20beb3409ce0f3feda1a99793956124a2967dd23afe1b528dd8fdeb82182",
"proposal:word/styles.xml": "dae1d6c1083bba6bb4b29ba2ab748a713f5da60b8c2cd4d52224c6f0a8f21bef",
"quote:page1.png": "0cb8e2ee78d96f816e490bd3830ccc6e7807c400c094872b42359284460c62af",
"quote:pdf/bytes": "b74bc8a08f1e66aabcb3334f2110e4da497cb2b467b773c9a61ac072d251db44",
"quote:pdf/bytes": "7cf33d2395adbe235271919ab35872300b230ba55d212adfed804c1f21f4a3e2",
"quote:pdf/pages": "bb61084121bf416506cd62bd49683e0b1773f13f72c6d0e15322826d5c4b129b",
"quote:pdf/resources": "c0433753ef113d5aad02e5ce159bf67aded188fb7828d2dc8c99293a81608313",
"quote:pdf/resources": "7e9626233181d07e8d0bfa3c302f6a457133de1e9b5a124ae0e0d53ed7dbe03d",
"quote:word/document.xml": "0c72970432f77927f6fb3bd6e980f9e12eebcf3a4c56b5260bf5902ef92ebdb5",
"quote:word/numbering.xml": "c6511604704117eb00ad2faffb9173e4f48d22f62557ca72a51d60b7907c8058",
"quote:word/styles.xml": "0dc0b047b6019b798b83bfe4d66eb91d14b03caea3f6bdb0934d48fbb863fba4",
"report:page1.png": "792f1fdc032c7ec1b6a3e451aa7f94fc45e8ea810d12b9815ced82d2fa7fbd82",
"report:pdf/bytes": "960f31cf7cf44328783d793eb1a3e61a66f96463e298af4d499fc08f080590dc",
"report:pdf/bytes": "eea0e88d4a6f70a07ee62606e346aff26df9179c5e38ac0767f1bb29457aa694",
"report:pdf/pages": "2e1bd079af84488e8c0c37f8d970e20cfe4c09cf586d293407069c3234ef9b39",
"report:pdf/resources": "cabc4cbad4abfbb595669566cc9ef98e9b96eb7c885f7fbce6a482a4e669b6bf",
"report:pdf/resources": "33cd91323ff09d9d7fad4cc4d83fc51954214304439eba29fb2a415752e1a669",
"report:word/document.xml": "7c30b636bc89becd68f098a70127532cbbbe284b008e2ed7cbb31a48d342d5ed",
"report:word/numbering.xml": "2aa0599486d98573be0b7febb04ec63c08d3dfc5341b0f722b93cae113803bbc",
"report:word/styles.xml": "0dc0b047b6019b798b83bfe4d66eb91d14b03caea3f6bdb0934d48fbb863fba4"
},
"reason": "Paragraph spacing keeps the larger of space after and space before and horizontal table borders take their width between rows, which moves the rendered PDF and PNG of six samples and no OOXML part"
"reason": "ToUnicode CMaps pair each glyph with the characters it draws through the font cmap and GSUB, so Carlito ligatures map to all their characters (GitHub issue #171)"
}
Loading
Loading