Files
wordcraft/crates/pdf/src/tests.rs
T
35e22e2cef Fixes: footnotes in table cells, NUMPAGES, bookmark link pages, bookmark around final table, INCLUDEPICTURE results (#450, #451, #455, #464, #465) (#483)
- layout: footnotes referenced in table-cell lines join the page's note area (#450)
- layout: NUMPAGES/SECTIONPAGES in the body use the real totals; the body is laid out
  again with the totals found (at most three extra passes); SECTION in the body counts
  sections (#451)
- pdf: internal link destinations use the page and line of the bookmark's start (#455)
- docx: markers left after a final table go to its last cell's last paragraph (#464)
- docx: a field whose cached result holds a picture is kept as a range field around it,
  so the picture survives save; the linked path is never read (#465)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 18:05:27 -04:00

801 lines
38 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::sync::Arc;
use wordcraft_doc::para::InlineObject;
use wordcraft_doc::props::NumRef;
use wordcraft_doc::{Block, CharProps, Document, ListKind, Paragraph, Table, Watermark, para_block};
use crate::*;
fn png() -> Vec<u8> {
let img = image::RgbaImage::from_fn(16, 8, |x, y| image::Rgba([(x * 15) as u8, (y * 30) as u8, 200, 255]));
let mut buf = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).unwrap();
buf
}
/// Headings, a bold run, a hyperlink, an internal link, a list, a table and a picture.
fn sample() -> Document {
let mut d = Document::new();
d.core.title = "PDF Sample".into();
d.core.creator = "Ada Writer".into();
d.core.subject = "Testing".into();
d.core.keywords = "pdf, export".into();
d.core.created = "2024-05-06T07:08:09Z".into();
let mut b = Vec::new();
let mut h1 = Paragraph::with_text("Introduction", CharProps::default()).styled("Heading1");
let n = h1.len();
h1.insert_object(n, InlineObject::BookmarkStart { name: "intro".into() }, &CharProps::default()).unwrap();
b.push(para_block(h1));
let mut p = Paragraph::with_text("Hello WordCraft, ", CharProps::default());
let n = p.len();
p.insert_text(n, "bold words", &CharProps { bold: Some(true), ..Default::default() }).unwrap();
let n = p.len();
p.insert_text(n, " and a website", &CharProps { link: Some("https://example.com/".into()), ..Default::default() }).unwrap();
let n = p.len();
p.insert_text(n, " back to top", &CharProps { link: Some("#intro".into()), italic: Some(true), ..Default::default() }).unwrap();
b.push(para_block(p));
b.push(para_block(Paragraph::with_text("Details", CharProps::default()).styled("Heading2")));
let list = d.numbering.add_list(ListKind::Numbered);
for t in ["alpha item", "beta item"] {
let mut q = Paragraph::with_text(t, CharProps::default()).styled("ListParagraph");
q.props.numbering = Some(NumRef { num: list, level: 0 });
b.push(para_block(q));
}
let mut t = Table::new(2, 2, 400.0);
for (r, c, s) in [(0, 0, "Cell00"), (0, 1, "Cell01"), (1, 0, "Cell10"), (1, 1, "Cell11")] {
t.rows[r].cells[c].blocks = vec![para_block(Paragraph::with_text(s, CharProps::default()))];
}
b.push(Arc::new(Block::Table(t)));
let key = d.add_media(png(), "png");
let mut ip = Paragraph::with_text("Figure: ", CharProps::default());
let n = ip.len();
ip.insert_object(
n,
InlineObject::Image {
media: key,
w: 80.0,
h: 40.0,
alt: "gradient".into(),
float: Default::default(),
crop: [0.1, 0.0, 0.0, 0.0],
ole: None,
},
&CharProps::default(),
)
.unwrap();
b.push(para_block(ip));
let mut big = Paragraph::with_text("Second page", CharProps::default()).styled("Heading1");
big.props.page_break_before = Some(true);
b.push(para_block(big));
b.push(para_block(Paragraph::with_text(
"Dotted and wavy",
CharProps { underline: Some(wordcraft_doc::props::Underline::Wave), ..Default::default() },
)));
d.body = b;
d.settings.watermark = Some(Watermark::default());
d.settings.page_color = Some(Rgb(0xFF, 0xFE, 0xF0));
d
}
/// Text of every page through hayro's interpreter (glyph → Unicode via ToUnicode / ActualText).
fn extract_text(bytes: &[u8]) -> Vec<String> {
use hayro_interpret::font::Glyph;
use hayro_interpret::{
BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask,
interpret_page,
};
use hayro_syntax::Pdf;
struct Ex(String);
impl Device<'_> for Ex {
fn set_soft_mask(&mut self, _: Option<SoftMask<'_>>) {}
fn set_blend_mode(&mut self, _: BlendMode) {}
fn draw_path(&mut self, _: &kurbo::BezPath, _: kurbo::Affine, _: &Paint<'_>, _: &PathDrawMode) {}
fn push_clip_path(&mut self, _: &ClipPath) {}
fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'_>>, _: BlendMode) {}
fn draw_glyph(&mut self, g: &Glyph<'_>, _: kurbo::Affine, _: kurbo::Affine, _: &Paint<'_>, mode: &GlyphDrawMode) {
// Synthetic bold fills and then strokes each glyph: one character, two draws.
if matches!(mode, GlyphDrawMode::Stroke(_)) {
return;
}
match g.as_unicode() {
Some(hayro_cmap::BfString::Char(c)) => self.0.push(c),
Some(hayro_cmap::BfString::String(s)) => self.0.push_str(&s),
None => self.0.push('\u{FFFD}'),
}
}
fn draw_image(&mut self, _: Image<'_, '_>, _: kurbo::Affine) {}
fn pop_clip_path(&mut self) {}
fn pop_transparency_group(&mut self) {}
}
let pdf = Pdf::new(bytes.to_vec()).expect("parse");
let cache = InterpreterCache::new();
pdf.pages()
.iter()
.map(|page| {
let mut ctx =
Context::new(kurbo::Affine::IDENTITY, kurbo::Rect::new(0.0, 0.0, 1.0, 1.0), &cache, pdf.xref(), InterpreterSettings::default());
let mut ex = Ex(String::new());
interpret_page(page, &mut ctx, &mut ex);
ex.0
})
.collect()
}
fn squash(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
#[test]
fn exports_pages_text_and_structure() {
let d = sample();
let lay = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
assert!(lay.pages.len() >= 2);
let bytes = export(&d, &PdfOptions::default()).unwrap();
assert!(bytes.starts_with(b"%PDF-"));
let pdf = hayro_syntax::Pdf::new(bytes.clone()).expect("parse");
assert_eq!(pdf.pages().len(), lay.pages.len());
let text = extract_text(&bytes);
let all = squash(&text.concat());
for w in [
"Introduction",
"Hello WordCraft,",
"bold words",
"website",
"Details",
"alpha item",
"beta item",
"Cell00",
"Cell11",
"Second page",
"DRAFT",
] {
assert!(all.contains(w), "{w:?} missing from {all:?}");
}
assert!(squash(&text[0]).contains("Hello WordCraft, bold words and a website back to top"), "{:?}", text[0]);
let raw = String::from_utf8_lossy(&export(&d, &PdfOptions { compress: false, ..Default::default() }).unwrap()).into_owned();
assert!(raw.contains("/FontFile2") || raw.contains("/FontFile3"), "embedded fonts");
assert!(raw.contains("/ToUnicode"));
assert!(raw.contains("/URI (https://example.com/)") || raw.contains("/URI(https://example.com/)"), "URL link");
assert!(raw.contains("/Subtype /Link") || raw.contains("/Subtype/Link"));
assert!(raw.contains("/Outlines"), "outline");
assert!(raw.contains("Introduction"));
assert!(raw.contains("/StructTreeRoot"), "tagged");
assert!(raw.contains("/Image"), "picture");
assert!(raw.contains("PDF Sample"), "title");
assert!(raw.contains("Ada Writer"), "author");
assert!(raw.contains("D:20240506070809"), "creation date");
}
#[test]
fn page_selection_and_errors() {
let d = sample();
let one = export(&d, &PdfOptions { pages: Some(vec![1]), tagged: false, ..Default::default() }).unwrap();
let pdf = hayro_syntax::Pdf::new(one.clone()).unwrap();
assert_eq!(pdf.pages().len(), 1);
assert!(squash(&extract_text(&one)[0]).contains("Second page"));
assert_eq!(export(&d, &PdfOptions { pages: Some(vec![99]), ..Default::default() }), Err(PdfError::BadPage(100)));
assert_eq!(export(&d, &PdfOptions { pages: Some(vec![]), ..Default::default() }), Err(PdfError::NoPages));
}
#[test]
fn hostile_documents_export() {
let mut d = Document::from_text("x");
d.last_section.page_w = f32::NAN;
d.last_section.page_h = 1e9;
d.media.insert("bad.png".into(), Arc::new(b"not an image".to_vec()));
let mut p = Paragraph::with_text("pic ", CharProps::default());
p.insert_object(
4,
InlineObject::Image {
media: "bad.png".into(),
w: f32::INFINITY,
h: -3.0,
alt: String::new(),
float: Default::default(),
crop: [f32::NAN; 4],
ole: None,
},
&CharProps::default(),
)
.unwrap();
p.insert_object(
0,
InlineObject::Image { media: "missing".into(), w: 10.0, h: 10.0, alt: String::new(), float: Default::default(), crop: [0.0; 4], ole: None },
&CharProps::default(),
)
.unwrap();
d.body.push(para_block(p));
let mut q =
Paragraph::with_text("Unknown font 😀 עברית", CharProps { font: Some("No Such Font".into()), size: Some(2000.0), ..Default::default() });
q.props.style = Some("Heading9".into());
d.body.push(para_block(q));
d.settings.watermark = Some(Watermark { text: " ".into(), ..Default::default() });
let _ = export(&d, &PdfOptions::default());
d.settings.watermark = Some(Watermark { text: "😀".repeat(500), font: "Nope".into(), diagonal: false, ..Default::default() });
let _ = export(&d, &PdfOptions { tagged: false, ..Default::default() });
assert!(export(&Document::new(), &PdfOptions::default()).unwrap().starts_with(b"%PDF"));
}
#[test]
fn tracked_deletions_are_left_out_without_markup() {
use wordcraft_doc::{Revision, RevisionKind};
let mut d = Document::new();
d.revisions.push(Revision { kind: RevisionKind::Delete, author: "Ada".into(), date: String::new(), move_name: None });
d.revisions.push(Revision { kind: RevisionKind::Insert, author: "Ada".into(), date: String::new(), move_name: None });
let mut p = Paragraph::with_text("Keep ", CharProps::default());
let n = p.len();
p.insert_text(n, "DELETEDTEXT ", &CharProps { del: Some(0), ..Default::default() }).unwrap();
let n = p.len();
p.insert_text(n, "INSERTED", &CharProps { ins: Some(1), ..Default::default() }).unwrap();
d.body = vec![para_block(p)];
// The default export shows the final text, as Word's "No Markup" does.
let text = squash(&extract_text(&export(&d, &PdfOptions::default()).unwrap()).concat());
assert_eq!(text, "Keep INSERTED");
// With markup the deletion is still there (struck through).
let text = squash(&extract_text(&export(&d, &PdfOptions { include_markup: true, ..Default::default() }).unwrap()).concat());
assert_eq!(text, "Keep DELETEDTEXT INSERTED");
}
#[test]
fn deleted_heading_text_stays_out_of_bookmarks_and_tags() {
use wordcraft_doc::{Revision, RevisionKind};
let mut d = Document::new();
d.revisions.push(Revision { kind: RevisionKind::Delete, author: "Ada".into(), date: String::new(), move_name: None });
let del = CharProps { del: Some(0), ..Default::default() };
// "Intro SECRET Part" with "SECRET " deleted, then a heading deleted as a whole.
let mut h = Paragraph::with_text("Intro ", CharProps::default()).styled("Heading1");
let n = h.len();
h.insert_text(n, "SECRET ", &del).unwrap();
let n = h.len();
h.insert_text(n, "Part", &CharProps::default()).unwrap();
let gone = Paragraph::with_text("GONE", del.clone()).styled("Heading1");
d.body = vec![para_block(h), para_block(Paragraph::with_text("Body text", CharProps::default())), para_block(gone)];
let raw = |opts: PdfOptions| String::from_utf8_lossy(&export(&d, &PdfOptions { compress: false, ..opts }).unwrap()).into_owned();
// Bookmarks alone (untagged), then bookmarks and heading tags: the final text only.
for tagged in [false, true] {
let pdf = raw(PdfOptions { tagged, ..Default::default() });
assert!(!pdf.contains("SECRET") && !pdf.contains("GONE"), "tagged {tagged}: deleted heading text in the PDF");
assert!(pdf.contains("/Outlines") && pdf.contains("Intro Part"), "tagged {tagged}: no bookmark for the heading");
}
// With markup the deleted text is shown, and so it is in the bookmarks.
let pdf = raw(PdfOptions { include_markup: true, ..Default::default() });
assert!(pdf.contains("Intro SECRET Part") && pdf.contains("GONE"));
}
#[test]
fn hidden_heading_text_stays_out_of_bookmarks_and_tags() {
let mut d = Document::new();
// Hidden text both directly and through a character style.
d.styles.upsert(wordcraft_doc::Style {
id: "Secret".into(),
name: "Secret".into(),
kind: wordcraft_doc::StyleKind::Character,
chr: CharProps { hidden: Some(true), ..Default::default() },
..Default::default()
});
let hidden = CharProps { hidden: Some(true), ..Default::default() };
// "Intro SECRET Part" with "SECRET " hidden, a heading hidden as a whole, and one hidden by style.
let mut h = Paragraph::with_text("Intro ", CharProps::default()).styled("Heading1");
let n = h.len();
h.insert_text(n, "SECRET ", &hidden).unwrap();
let n = h.len();
h.insert_text(n, "Part", &CharProps::default()).unwrap();
let gone = Paragraph::with_text("GONE", hidden.clone()).styled("Heading1");
let styled = Paragraph::with_text("STYLED", CharProps { style: Some("Secret".into()), ..Default::default() }).styled("Heading1");
d.body = vec![
para_block(h),
para_block(Paragraph::with_text("Body text", CharProps::default())),
para_block(gone),
para_block(styled),
para_block(Paragraph::with_text("More text", CharProps::default())),
];
let raw = |opts: PdfOptions| String::from_utf8_lossy(&export(&d, &PdfOptions { compress: false, ..opts }).unwrap()).into_owned();
// The PDF never prints hidden text, with or without markup, so neither do bookmarks or tags.
for (tagged, include_markup) in [(false, false), (true, false), (true, true)] {
let pdf = raw(PdfOptions { tagged, include_markup, ..Default::default() });
let what = format!("tagged {tagged} markup {include_markup}");
assert!(!pdf.contains("SECRET") && !pdf.contains("GONE") && !pdf.contains("STYLED"), "{what}: hidden heading text in the PDF");
assert!(pdf.contains("/Outlines") && pdf.contains("Intro Part"), "{what}: no bookmark for the heading");
}
}
#[test]
fn long_fragmented_hidden_headings_export_in_linear_time() {
// Headings of n chars in one-char runs: all hidden with bold alternating, and hidden and visible
// alternating. Layout and the bookmark/tag titles once scanned every left-out range for every
// char, quadratic in n. Machine-independent: the time for 4n against n, linear ~4, quadratic ~16.
let doc = |n: usize| {
let heading = |visible_every_other: bool| {
let mut h = Paragraph::with_text(&"abcdefg ".repeat(n / 8), CharProps::default()).styled("Heading1");
h.runs = (0..n)
.map(|i| {
let hidden = !(visible_every_other && i % 2 == 1);
wordcraft_doc::Run { len: 1, props: CharProps { hidden: Some(hidden), bold: Some(i % 4 < 2), ..Default::default() } }
})
.collect();
para_block(h)
};
let mut d = Document::new();
d.body = vec![heading(false), heading(true), para_block(Paragraph::with_text("After", CharProps::default()))];
d
};
let opts = PdfOptions { compress: false, ..Default::default() };
// The fastest of a few runs, after a warm-up, so one slow run on a busy machine counts less.
let fastest = |d: &Document| {
(0..3)
.map(|_| {
let t = std::time::Instant::now();
export(d, &opts).unwrap();
t.elapsed().as_secs_f64()
})
.fold(f64::INFINITY, f64::min)
};
let n = 25_000;
let (small, big) = (doc(n), doc(4 * n));
export(&small, &opts).unwrap();
let (t1, t4) = (fastest(&small), fastest(&big));
let ratio = t4 / t1.max(1e-9);
eprintln!("t(n) {t1:.3} s, t(4n) {t4:.3} s, ratio {ratio:.1}");
assert!(ratio < 8.0, "4x the text took {ratio:.1}x the time ({t1:.3} s → {t4:.3} s): not linear");
let pdf = String::from_utf8_lossy(&export(&big, &opts).unwrap()).into_owned();
// The all-hidden heading has no bookmark; the other is titled with its visible chars only.
assert!(!pdf.contains("abcd"), "hidden heading text in a title");
assert!(pdf.contains("bdf bdf"), "no title for the half-visible heading");
}
#[test]
fn table_style_hidden_heading_text_stays_out_of_bookmarks_and_tags() {
// Hidden by the table style's header row, so only the layout's resolution (table conditional
// formatting under the runs' own) says the text is hidden.
let mut d = Document::new();
d.styles.upsert(wordcraft_doc::Style {
id: "SecretHeader".into(),
name: "Secret Header".into(),
kind: wordcraft_doc::StyleKind::Table,
table: Some(wordcraft_doc::styles::TableStyleParts {
header_chr: CharProps { hidden: Some(true), ..Default::default() },
..Default::default()
}),
..Default::default()
});
let mut t = Table::new(2, 2, 400.0);
t.props.style = Some("SecretHeader".into());
// Header row: a heading hidden as a whole, and "Shown SECRET" where "Shown " is unhidden directly.
let mut partly = Paragraph::with_text("Shown ", CharProps { hidden: Some(false), ..Default::default() }).styled("Heading1");
let n = partly.len();
partly.insert_text(n, "SECRET", &CharProps::default()).unwrap();
t.rows[0].cells[0].blocks = vec![para_block(Paragraph::with_text("GONE", CharProps::default()).styled("Heading1"))];
t.rows[0].cells[1].blocks = vec![para_block(partly)];
t.rows[1].cells[0].blocks = vec![para_block(Paragraph::with_text("Body cell", CharProps::default()))];
d.body = vec![para_block(Paragraph::with_text("Before", CharProps::default())), Arc::new(Block::Table(t))];
let raw = |opts: PdfOptions| String::from_utf8_lossy(&export(&d, &PdfOptions { compress: false, ..opts }).unwrap()).into_owned();
for (tagged, include_markup) in [(false, false), (true, false), (true, true)] {
let pdf = raw(PdfOptions { tagged, include_markup, ..Default::default() });
let what = format!("tagged {tagged} markup {include_markup}");
assert!(!pdf.contains("SECRET") && !pdf.contains("GONE"), "{what}: hidden table heading text in the PDF");
assert!(pdf.contains("/Outlines") && pdf.contains("Shown"), "{what}: no bookmark for the visible part");
}
}
#[test]
fn text_mapping_handles_ligatures_and_extras() {
let d = Document::from_text("office affine");
let lay = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
let bytes = export_layout(&d, &lay, &PdfOptions::default()).unwrap();
assert!(squash(&extract_text(&bytes)[0]).contains("office affine"));
}
#[test]
fn shapes_have_paths() {
let r = Rect::new(0.0, 0.0, 10.0, 10.0);
for k in [
ShapeKind::Rectangle,
ShapeKind::RoundedRectangle,
ShapeKind::Ellipse,
ShapeKind::Star,
ShapeKind::Heart,
ShapeKind::Arrow,
ShapeKind::Line,
ShapeKind::Diamond,
ShapeKind::Triangle,
] {
assert!(shape_path(k, &r).is_some(), "{k:?}");
}
assert!(shape_path(ShapeKind::Ellipse, &Rect::new(0.0, 0.0, f32::NAN, 1.0)).is_none());
assert_eq!(parse_iso("2024-02-03T04:05:06Z"), Some((2024, 2, 3, 4, 5, 6)));
assert_eq!(parse_iso("garbage"), None);
assert_eq!(civil(0), (1970, 1, 1, 0, 0, 0));
}
#[test]
fn fields_put_their_shown_text_in_the_text_layer() {
// "Page 1 of 2" in the footer: the page fields' numbers, not the objects' placeholder
// characters, are what copying, search and screen readers get.
let mut d = Document::new();
let mut f = Paragraph::with_text("Page of ", CharProps::default());
let field = |instr: &str| InlineObject::Field { instr: instr.into(), result: String::new(), locked: false, code: false };
f.insert_object(5, field("PAGE"), &CharProps::default()).unwrap();
let n = f.len();
f.insert_object(n, field("NUMPAGES"), &CharProps::default()).unwrap();
let id = d.add_part(wordcraft_doc::PartKind::Footer, vec![para_block(f)]);
d.last_section.footers.default = Some(id);
let mut body = Paragraph::with_text("Body", CharProps::default());
body.props.page_break_before = None;
let mut second = Paragraph::with_text("More", CharProps::default());
second.props.page_break_before = Some(true);
d.body = vec![para_block(body), para_block(second)];
let text = extract_text(&export(&d, &PdfOptions::default()).unwrap());
assert_eq!(text.len(), 2);
assert!(text.iter().all(|t| !t.contains('\u{FFFC}')), "{text:?}");
assert!(squash(&text[0]).contains("Page 1 of 2") && squash(&text[1]).contains("Page 2 of 2"), "{text:?}");
}
/// Bundled Source Sans 3 renamed to `family`, with glyph `gid` turned into a composite glyph whose
/// component list is cut off: fonts still parse and shape, but krilla's subsetter rejects it.
fn unsubsettable_font(family: &str, gid: u16) -> Vec<u8> {
let mut f = wordcraft_fonts::bundled()[0].to_vec();
let rename = |f: &mut Vec<u8>, from: &[u8], to: &[u8]| {
let mut i = 0;
while i + from.len() <= f.len() {
if &f[i..i + from.len()] == from {
f[i..i + from.len()].copy_from_slice(to);
}
i += 1;
}
};
let wide = |s: &str| s.encode_utf16().flat_map(|u| u.to_be_bytes()).collect::<Vec<u8>>();
assert_eq!(family.len(), "Source Sans 3".len());
rename(&mut f, &wide("Source Sans 3"), &wide(family));
rename(&mut f, b"Source Sans 3", family.as_bytes());
let u16_at = |f: &[u8], o: usize| u16::from_be_bytes([f[o], f[o + 1]]);
let u32_at = |f: &[u8], o: usize| u32::from_be_bytes([f[o], f[o + 1], f[o + 2], f[o + 3]]);
let table = |f: &[u8], tag: &[u8]| {
(0..u16_at(f, 4) as usize).map(|t| 12 + t * 16).find(|r| &f[*r..*r + 4] == tag).map(|r| u32_at(f, r + 8) as usize).unwrap()
};
let (head, loca, glyf) = (table(&f, b"head"), table(&f, b"loca"), table(&f, b"glyf"));
let g = gid as usize;
let off = if u16_at(&f, head + 50) == 0 {
// Short offsets (halved): the glyph keeps only its 10-byte header.
let start = u16_at(&f, loca + g * 2);
f[loca + g * 2 + 2..loca + g * 2 + 4].copy_from_slice(&(start + 5).to_be_bytes());
start as usize * 2
} else {
let start = u32_at(&f, loca + g * 4);
f[loca + g * 4 + 4..loca + g * 4 + 8].copy_from_slice(&(start + 10).to_be_bytes());
start as usize
};
// numberOfContours = -1: a composite glyph, with no room left for its components.
f[glyf + off..glyf + off + 2].copy_from_slice(&(-1i16).to_be_bytes());
f
}
/// Glyphs drawn as text and paths drawn, over every page.
fn count_glyphs_and_paths(bytes: &[u8]) -> (usize, usize) {
use hayro_interpret::font::Glyph;
use hayro_interpret::{
BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask,
interpret_page,
};
use hayro_syntax::Pdf;
struct Count(usize, usize);
impl Device<'_> for Count {
fn set_soft_mask(&mut self, _: Option<SoftMask<'_>>) {}
fn set_blend_mode(&mut self, _: BlendMode) {}
fn draw_path(&mut self, _: &kurbo::BezPath, _: kurbo::Affine, _: &Paint<'_>, _: &PathDrawMode) {
self.1 += 1;
}
fn push_clip_path(&mut self, _: &ClipPath) {}
fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'_>>, _: BlendMode) {}
fn draw_glyph(&mut self, _: &Glyph<'_>, _: kurbo::Affine, _: kurbo::Affine, _: &Paint<'_>, _: &GlyphDrawMode) {
self.0 += 1;
}
fn draw_image(&mut self, _: Image<'_, '_>, _: kurbo::Affine) {}
fn pop_clip_path(&mut self) {}
fn pop_transparency_group(&mut self) {}
}
let pdf = Pdf::new(bytes.to_vec()).expect("parse");
let cache = InterpreterCache::new();
let mut n = Count(0, 0);
for page in pdf.pages().iter() {
let mut ctx = Context::new(kurbo::Affine::IDENTITY, kurbo::Rect::new(0.0, 0.0, 1.0, 1.0), &cache, pdf.xref(), InterpreterSettings::default());
interpret_page(page, &mut ctx, &mut n);
}
(n.0, n.1)
}
#[test]
fn unsubsettable_font_is_drawn_as_outlines() {
let db = wordcraft_fonts::FontDb::global();
let o = db.face("Source Sans 3", "Regular").glyph_for('o');
let family = "Broken Sans 3";
db.add_font(unsubsettable_font(family, u16::try_from(o).unwrap()));
assert!(db.has_family(family));
// Leave out the glyph after the broken one: its start moved into the broken glyph's data.
let text: String = "Broken font, good export".chars().filter(|c| db.face(family, "Regular").glyph_for(*c) != o + 1).collect();
let doc = |font: &str| {
let mut d = Document::new();
d.body = vec![para_block(Paragraph::with_text(&text, CharProps { font: Some(font.into()), ..Default::default() }))];
d
};
let opts = PdfOptions { tagged: false, ..Default::default() };
// The intact font is embedded as text.
let (glyphs, _) = count_glyphs_and_paths(&export(&doc("Source Sans 3"), &opts).unwrap());
assert!(glyphs > 0);
// The broken one used to fail the whole export ("failed to subset font: malformed font");
// now its text is drawn as outlines.
let bytes = export(&doc(family), &opts).unwrap();
let (glyphs, paths) = count_glyphs_and_paths(&bytes);
assert_eq!(glyphs, 0, "no text in the font krilla can't subset");
assert!(paths > 0, "its glyphs are drawn as outlines");
}
#[test]
fn field_results_are_extractable_text() {
// Issue #97 (test from #112 by @LloydNicholson): a REF field's result, not the U+FFFC
// placeholder, is what the text layer gets.
let mut d = Document::new();
let mut p = Paragraph::with_text("See for details.", CharProps::default());
p.insert_object(
4,
InlineObject::Field { instr: " REF _RefTarget \\h ".into(), result: "Section 3.01".into(), locked: false, code: false },
&CharProps::default(),
)
.unwrap();
d.body = vec![para_block(p)];
let text = extract_text(&export(&d, &PdfOptions::default()).unwrap()).concat();
assert!(!text.contains('\u{FFFC}'), "{text:?}");
assert!(squash(&text).contains("See Section 3.01 for details."), "{text:?}");
}
#[test]
fn field_result_ligatures_extract_all_their_letters() {
// A field result is shaped as one cluster; a ligature in it (Carlito, the Calibri substitute,
// joins "ti") used to get only its first letter, shifting the rest: "Sectio 3.01". Bundled
// Source Sans 3 joins "fi" and "ffi", so this doesn't depend on the installed fonts.
let font = CharProps { font: Some("Source Sans 3".into()), ..Default::default() };
let result = "the first office";
let face = wordcraft_fonts::FontDb::global().face("Source Sans 3", "Regular");
assert!(wordcraft_fonts::shape(&face, result, &[], |c| c).len() < result.chars().count(), "the result has ligatures");
let mut d = Document::new();
let mut p = Paragraph::with_text("See here.", font.clone());
p.insert_object(4, InlineObject::Field { instr: " REF _RefTarget \\h ".into(), result: result.into(), locked: false, code: false }, &font)
.unwrap();
d.body = vec![para_block(p)];
let text = extract_text(&export(&d, &PdfOptions::default()).unwrap()).concat();
assert!(squash(&text).contains("See the first office here."), "{text:?}");
}
#[test]
fn synthetic_bold_text_is_extracted_once() {
// JetBrains Mono ships without a bold face, so its bold is filled and stroked: the text
// layer still holds each character once.
let mut d = Document::new();
let bold = CharProps { font: Some("JetBrains Mono".into()), bold: Some(true), ..Default::default() };
d.body = vec![para_block(Paragraph::with_text("Mono bold", bold))];
let text = squash(&extract_text(&export(&d, &PdfOptions::default()).unwrap()).concat());
assert!(text.contains("Mono bold"), "{text:?}");
}
#[test]
fn turned_cell_text_exports_as_text() {
// Table Layout › Text Direction (#226): turned cell text is drawn in a turned frame and
// stays real text in the PDF.
for dir in [wordcraft_doc::props::TextDirection::Down, wordcraft_doc::props::TextDirection::Up] {
let mut t = Table::new(1, 2, 300.0);
t.rows[0].cells[0].blocks = vec![para_block(Paragraph::with_text("Turned", CharProps::default()))];
t.rows[0].cells[0].props.text_direction = dir;
let mut d = Document::new();
d.body = vec![Arc::new(Block::Table(t)), para_block(Paragraph::with_text("Below", CharProps::default()))];
let text = squash(&extract_text(&export(&d, &PdfOptions::default()).unwrap()).concat());
assert!(text.contains("Turned") && text.contains("Below"), "{dir:?}: {text:?}");
}
}
#[test]
fn inline_chart_is_a_figure_with_its_text_inside_it() {
use wordcraft_doc::graphic::{Graphic, GraphicItem, GraphicKind, TextAlign};
use wordcraft_doc::props::Rgb;
let items = vec![GraphicItem::Text {
rect: [0.0, 0.0, 216.0, 20.0],
text: "Q1".into(),
size: 10.0,
color: Rgb::BLACK,
bold: false,
align: TextAlign::Center,
font: None,
}];
let graphic = Graphic { kind: GraphicKind::Chart, items, ..Default::default() };
let obj = InlineObject::Graphic { w: 216.0, h: 144.0, alt: "Sales by quarter".into(), float: Default::default(), graphic: Arc::new(graphic) };
let mut d = Document::from_text("Chart");
d.insert_object(&wordcraft_doc::Pos::body(0, 0), obj, &Default::default()).unwrap();
let raw = |tagged| String::from_utf8_lossy(&export(&d, &PdfOptions { tagged, compress: false, ..Default::default() }).unwrap()).into_owned();
assert!(raw(true).contains("/Figure"), "tagged chart is a figure");
assert!(!raw(false).contains("/Figure"));
}
#[test]
fn chart_without_alt_text_is_a_figure_called_chart() {
use wordcraft_doc::graphic::{Graphic, GraphicItem, GraphicKind, PathSeg};
let segs = vec![PathSeg::Move(1.0, 1.0), PathSeg::Line(50.0, 50.0)];
let items = vec![GraphicItem::Path { segs, fill: None, stroke: Some(wordcraft_doc::props::Rgb::BLACK), stroke_width: 1.0 }];
let graphic = Graphic { kind: GraphicKind::Chart, items, ..Default::default() };
let obj = InlineObject::Graphic { w: 216.0, h: 144.0, alt: String::new(), float: Default::default(), graphic: Arc::new(graphic) };
let mut d = Document::from_text("Chart");
d.insert_object(&wordcraft_doc::Pos::body(0, 0), obj, &Default::default()).unwrap();
let raw = String::from_utf8_lossy(&export(&d, &PdfOptions { tagged: true, compress: false, ..Default::default() }).unwrap()).into_owned();
assert!(raw.contains("/Alt(chart)"), "no chart alt text");
assert!(!raw.contains("/Alt(picture)"));
}
/// A 25 mm square EMF with a red rectangle over its right half (device units 0.04 per 0.01 mm).
fn red_right_half_emf() -> Vec<u8> {
fn rec(typ: u32, payload: &[u8]) -> Vec<u8> {
let mut v = typ.to_le_bytes().to_vec();
v.extend(((8 + payload.len()) as u32).to_le_bytes());
v.extend(payload);
v
}
let le32 = |vals: &[i32]| -> Vec<u8> { vals.iter().flat_map(|v| v.to_le_bytes()).collect() };
let mut h = vec![0u8; 88];
h[0..4].copy_from_slice(&1u32.to_le_bytes());
h[4..8].copy_from_slice(&88u32.to_le_bytes());
h[8..24].copy_from_slice(&le32(&[0, 0, 100, 100]));
h[24..40].copy_from_slice(&le32(&[0, 0, 2500, 2500]));
h[40..44].copy_from_slice(&0x464D_4520u32.to_le_bytes());
h[72..88].copy_from_slice(&le32(&[1000, 1000, 250, 250]));
[h, rec(39, &le32(&[1, 0, 0x0000_00FF, 0])), rec(37, &le32(&[1])), rec(43, &le32(&[50, 0, 100, 100])), rec(14, &[0; 12])].concat()
}
#[test]
fn metafile_pictures_are_drawn_as_vector_paths() {
let mut d = Document::new();
let media = d.add_media(red_right_half_emf(), "emf");
let mut p = Paragraph::with_text("Figure: ", CharProps::default());
let n = p.len();
p.insert_object(
n,
InlineObject::Image { media, w: 70.0, h: 70.0, alt: "red".into(), float: Default::default(), crop: [0.0; 4], ole: None },
&CharProps::default(),
)
.unwrap();
d.body = vec![para_block(p)];
let bytes = export(&d, &PdfOptions { compress: false, tagged: false, ..Default::default() }).unwrap();
let raw = String::from_utf8_lossy(&bytes);
assert!(raw.contains("1 0 0 rg"), "the red rectangle is filled as a path");
assert!(!raw.contains("0.8156"), "the grey placeholder box is not drawn");
}
/// An EMF file of the given records, with a 25 mm frame and 1000 device pixels across (0.04 per 0.01 mm).
fn emf_file(records: &[Vec<u8>]) -> Vec<u8> {
let le32 = |vals: &[i32]| -> Vec<u8> { vals.iter().flat_map(|v| v.to_le_bytes()).collect() };
let body = records.concat();
let mut h = vec![0u8; 88];
h[0..4].copy_from_slice(&1u32.to_le_bytes());
h[4..8].copy_from_slice(&88u32.to_le_bytes());
h[8..24].copy_from_slice(&le32(&[0, 0, 100, 100]));
h[24..40].copy_from_slice(&le32(&[0, 0, 2500, 2500]));
h[40..44].copy_from_slice(&0x464D_4520u32.to_le_bytes());
h[72..88].copy_from_slice(&le32(&[1000, 1000, 250, 250]));
[h, body].concat()
}
/// One EMF record of the given type with little-endian i32 fields.
fn emf_rec(typ: u32, fields: &[i32]) -> Vec<u8> {
let mut v = typ.to_le_bytes().to_vec();
let payload: Vec<u8> = fields.iter().flat_map(|f| f.to_le_bytes()).collect();
v.extend(((8 + payload.len()) as u32).to_le_bytes());
v.extend(payload);
v
}
#[test]
fn metafile_strokes_use_round_caps_and_joins() {
// A 4-unit black pen (handle 1) outlines a rectangle, so the stroke is drawn with round caps and joins.
let recs =
[emf_rec(38, &[1, 0, 4, 0, 0]), emf_rec(37, &[1]), emf_rec(37, &[0x8000_0005u32 as i32]), emf_rec(43, &[0, 0, 50, 50]), emf_rec(14, &[0; 3])];
let mut d = Document::new();
let media = d.add_media(emf_file(&recs), "emf");
let mut p = Paragraph::with_text("Figure: ", CharProps::default());
let n = p.len();
p.insert_object(
n,
InlineObject::Image { media, w: 70.0, h: 70.0, alt: String::new(), float: Default::default(), crop: [0.0; 4], ole: None },
&CharProps::default(),
)
.unwrap();
d.body = vec![para_block(p)];
let bytes = export(&d, &PdfOptions { compress: false, tagged: false, ..Default::default() }).unwrap();
let raw = String::from_utf8_lossy(&bytes);
assert!(raw.contains("1 J"), "round line caps");
assert!(raw.contains("1 j"), "round line joins");
}
#[test]
fn metafile_fill_items_carry_no_stroke_state() {
// A stroked outline (NULL brush) followed by a filled rectangle (NULL pen): the fill must not inherit the stroke.
let recs = [
emf_rec(38, &[1, 0, 4, 0, 0]),
emf_rec(37, &[1]),
emf_rec(37, &[0x8000_0005u32 as i32]),
emf_rec(43, &[0, 0, 50, 50]),
emf_rec(39, &[2, 0, 0x0000_00FF, 0]),
emf_rec(37, &[2]),
emf_rec(37, &[0x8000_0008u32 as i32]),
emf_rec(43, &[60, 0, 100, 50]),
emf_rec(14, &[0; 3]),
];
let mut d = Document::new();
let media = d.add_media(emf_file(&recs), "emf");
let mut p = Paragraph::with_text("Figure: ", CharProps::default());
let n = p.len();
p.insert_object(
n,
InlineObject::Image { media, w: 70.0, h: 70.0, alt: String::new(), float: Default::default(), crop: [0.0; 4], ole: None },
&CharProps::default(),
)
.unwrap();
d.body = vec![para_block(p)];
let bytes = export(&d, &PdfOptions { compress: false, tagged: false, ..Default::default() }).unwrap();
let raw = String::from_utf8_lossy(&bytes);
let fill = raw.find("1 0 0 rg").expect("the red fill is drawn");
let rest = &raw[fill..];
let rest = &rest[..rest.find("endstream").unwrap_or(rest.len())];
assert!(raw[..fill].contains("0 0 0 RG"), "the outline is stroked before the fill");
assert!(!rest.contains(" RG") && !rest.contains("\nS\n"), "the fill item carries no stroke: {rest:?}");
}
#[test]
fn cosmetic_metafile_pens_are_thin_but_visible() {
// MOVETO, LINETO with the default cosmetic (width 0) pen: drawn 0.25 pt wide, not 0.1 pt.
let recs = [emf_rec(27, &[0, 50]), emf_rec(54, &[100, 50]), emf_rec(14, &[0; 3])];
let mut d = Document::new();
let media = d.add_media(emf_file(&recs), "emf");
let mut p = Paragraph::with_text("Figure: ", CharProps::default());
let n = p.len();
p.insert_object(
n,
InlineObject::Image { media, w: 70.0, h: 70.0, alt: String::new(), float: Default::default(), crop: [0.0; 4], ole: None },
&CharProps::default(),
)
.unwrap();
d.body = vec![para_block(p)];
let bytes = export(&d, &PdfOptions { compress: false, tagged: false, ..Default::default() }).unwrap();
let raw = String::from_utf8_lossy(&bytes);
assert!(raw.contains("0.25 w"), "the hairline is a quarter point wide");
}
/// #455: a link to a bookmark after a page break inside its paragraph goes to the page the
/// bookmark is on, not the paragraph's first page.
#[test]
fn bookmark_destination_is_the_page_of_its_start() {
let mut d = Document::new();
let mut p = Paragraph::with_text("Paragraph start\u{c}Bookmark target", CharProps::default());
let at = "Paragraph start\u{c}".len();
p.insert_object(at, InlineObject::BookmarkStart { name: "target".into() }, &CharProps::default()).unwrap();
p.insert_object(0, InlineObject::BookmarkStart { name: "first".into() }, &CharProps::default()).unwrap();
d.body = vec![para_block(p)];
let lay = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
assert_eq!(lay.pages.len(), 2);
let pages: Vec<usize> = (0..lay.pages.len()).collect();
let outlined = HashSet::new();
let ex = Exporter {
doc: &d,
lay: &lay,
opts: &PdfOptions::default(),
out_index: pages.iter().enumerate().map(|(o, p)| (*p, o)).collect(),
fonts: HashMap::new(),
outlined: &outlined,
cmaps: HashMap::new(),
images: HashMap::new(),
vectors: HashMap::new(),
tags: Vec::new(),
tag_index: HashMap::new(),
links: Vec::new(),
};
let (page, x, y) = ex.bookmark("target").unwrap();
assert_eq!(page, 1, "the bookmark is on the second page");
assert!((x - 72.0).abs() < 1.0 && y < 150.0, "{x} {y}");
assert_eq!(ex.bookmark("first").map(|b| b.0), Some(0));
}