From 40becaa3c2cbd61bb3f3a9e85e55ab562792eb08 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Mon, 9 Nov 2026 11:17:34 +0000 Subject: [PATCH] #6005: strict export checks metafile text; share note part names; count a chunk's unlisted pictures once - Text in a WMF/EMF picture is shaped with the exporters' font resolution (shared metafile_text_run) and its missing glyphs join the MissingGlyphs report. - The reader keeps one Arc per source part name instead of a String per note or comment. - A chunk imported twice adds its pictures past the list cap once (MissingPictures::add_unlisted). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FypgCX8G9hbs6XYZFsgjbL --- README.md | 2 +- apps/wordcraft-cli/tests/it/strict.rs | 71 ++++++++++++++++++++++++ crates/doc/src/missing.rs | 14 ++++- crates/docx/src/read.rs | 12 ++-- crates/docx/src/read/chunk.rs | 2 +- crates/docx/src/read/glossary.rs | 2 +- crates/docx/src/read/parts.rs | 7 ++- crates/docx/tests/it/alt_chunks.rs | 10 ++++ crates/docx/tests/it/missing_pictures.rs | 16 +++++- crates/layout/src/lib.rs | 2 +- crates/layout/src/missing.rs | 70 +++++++++++++++++++++-- crates/layout/src/strict.rs | 2 +- crates/pdf/src/exporter/text.rs | 3 +- crates/render/src/media.rs | 3 +- 14 files changed, 192 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 0cab44b258..66a7b7776a 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,7 @@ story part it sits in such as `word/header2.xml`, and a reason code; `source_par (`300 characters cannot be drawn (first 256 listed)`). Each list stops at 256 entries separately; `truncated` is true when either was cut, and the totals count every distinct character and picture found, those past the cap included. Only characters the output draws count (not hidden text, or deleted text -without `--markup`), and a character a fallback font draws is fine. A Private Use Area character +without `--markup`), and a character a fallback font draws is fine. Text inside a WMF or EMF picture counts too, shaped in the font the picture names with no fallback. A Private Use Area character fails only if no installed font maps it. The result depends on the fonts installed on the host (see #5624 for a fixed font set). An external picture target is never fetched: it is reported, not loaded. Pictures dropped when a DOCX is opened (a missing part, an external target, or a `link only` picture with no embedded copy) are reported for DOCX input only. They have no page, so a PNG of any page is also refused for a missing or external picture anywhere in the document, unused headers included. `render --strict` checks only the page it diff --git a/apps/wordcraft-cli/tests/it/strict.rs b/apps/wordcraft-cli/tests/it/strict.rs index 6695abb6d9..2ea53c5e91 100644 --- a/apps/wordcraft-cli/tests/it/strict.rs +++ b/apps/wordcraft-cli/tests/it/strict.rs @@ -394,3 +394,74 @@ fn usage_errors_keep_their_line_breaks() { let all = format!("{}{}", String::from_utf8_lossy(&r.stdout), stderr(&r)); assert!(all.lines().count() > 3 && all.lines().any(|l| l == "USAGE:"), "{all}"); } + +/// A minimal EMF (88-byte header, 100 mm square frame) with one EMR_EXTTEXTOUTW record showing +/// `text` in the default font. +fn emf_with_text(text: &str) -> Vec { + let units: Vec = text.encode_utf16().collect(); + let mut rec = vec![0u8; 76]; + rec[0..4].copy_from_slice(&84u32.to_le_bytes()); + rec[36..40].copy_from_slice(&10i32.to_le_bytes()); + rec[40..44].copy_from_slice(&40i32.to_le_bytes()); + rec[44..48].copy_from_slice(&(units.len() as u32).to_le_bytes()); + rec[48..52].copy_from_slice(&76u32.to_le_bytes()); + rec.extend(units.iter().flat_map(|u| u.to_le_bytes())); + while !rec.len().is_multiple_of(4) { + rec.push(0); + } + let len = rec.len() as u32; + rec[4..8].copy_from_slice(&len.to_le_bytes()); + let eof: Vec = [14u32.to_le_bytes(), 20u32.to_le_bytes(), [0; 4], [0; 4], 20u32.to_le_bytes()].concat(); + let total = 88 + rec.len() + eof.len(); + 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(&[0i32, 0, 100, 100].iter().flat_map(|v| v.to_le_bytes()).collect::>()); + h[24..40].copy_from_slice(&[0i32, 0, 10000, 10000].iter().flat_map(|v| v.to_le_bytes()).collect::>()); + h[40..44].copy_from_slice(&0x464D_4520u32.to_le_bytes()); + h[44..48].copy_from_slice(&0x0001_0000u32.to_le_bytes()); + h[48..52].copy_from_slice(&(total as u32).to_le_bytes()); + h[52..56].copy_from_slice(&2u32.to_le_bytes()); + h[56..58].copy_from_slice(&1u16.to_le_bytes()); + h[72..76].copy_from_slice(&1000i32.to_le_bytes()); + h[76..80].copy_from_slice(&1000i32.to_le_bytes()); + h[80..84].copy_from_slice(&250i32.to_le_bytes()); + h[84..88].copy_from_slice(&250i32.to_le_bytes()); + [h, rec, eof].concat() +} + +/// The text a metafile picture draws is checked like the page's own: a character no face covers +/// refuses the export from `convert` and `render`, naming the code point, and nothing is written. +#[test] +fn a_character_in_a_metafile_pictures_text_fails_strict_like_the_pages_own() { + if without_fonts() { + return; + } + let c = uncovered(); + let cp = format!("U+{:04X}", c as u32); + let d = dir("emf-text"); + let emf = emf_with_text(&format!("ab{c}")); + let input = d.join("in.docx"); + std::fs::write(&input, docx_with_header_picture(&emf)).unwrap(); + for out in ["out.pdf", "out.png"] { + let out = d.join(out); + let r = convert(&input, &out, true); + assert_eq!(r.status.code(), Some(3), "{}", stderr(&r)); + assert_eq!(report_json(&r)["missing_glyphs"][0]["cp"], cp.as_str(), "{}", stderr(&r)); + assert!(!out.exists(), "{} was written", out.display()); + // Without --strict it converts, the character a hollow box. + let r = convert(&input, &out, false); + assert_eq!(r.status.code(), Some(0), "{}", stderr(&r)); + std::fs::remove_file(&out).unwrap(); + } + let out = d.join("p1.png"); + let r = render(&input, &out, &["--strict"]); + assert_eq!(r.status.code(), Some(3), "{}", stderr(&r)); + assert!(r.stdout.is_empty() && !out.exists()); + assert_eq!(report_json(&r)["missing_glyphs"][0]["cp"], cp.as_str()); + // Text the faces cover passes. + std::fs::write(&input, docx_with_header_picture(&emf_with_text("abc"))).unwrap(); + let r = convert(&input, &d.join("ok.pdf"), true); + assert_eq!(r.status.code(), Some(0), "{}", stderr(&r)); + std::fs::remove_dir_all(d).unwrap(); +} diff --git a/crates/doc/src/missing.rs b/crates/doc/src/missing.rs index 68e192d79d..d60d93c141 100644 --- a/crates/doc/src/missing.rs +++ b/crates/doc/src/missing.rs @@ -81,7 +81,7 @@ pub struct MissingPictures { /// A header, footer, note or comment story's part id ([`crate::Document::parts`]) to the /// part it was read from (`word/header2.xml`). Ids freed and reused after opening may name /// the old part. - pub part_names: std::collections::BTreeMap, + pub part_names: std::collections::BTreeMap>, /// A [`crate::Document::media`] key to the part its bytes were read from /// (`word/media/image2.bin`). pub media_parts: std::collections::BTreeMap, @@ -89,9 +89,21 @@ pub struct MissingPictures { seen: std::collections::HashSet, /// Hashes of the distinct pictures past the cap (8 bytes each), so the total counts them once. unlisted: std::collections::HashSet, + /// The chunk parts whose unlisted pictures [`Self::add_unlisted`] has counted, so a chunk + /// imported twice counts them once. + counted_chunks: std::collections::HashSet, } impl MissingPictures { + /// Add `n` pictures of the chunk part `chunk` that its reader counted but did not list. The + /// same chunk part counts once however often it is imported (its listed pictures dedupe + /// through [`Self::push`]). + pub fn add_unlisted(&mut self, chunk: &str, n: u64) { + if n > 0 && self.counted_chunks.insert(chunk.to_string()) { + self.total = self.total.saturating_add(n); + } + } + /// Add one (the same part, story and reason once). pub fn push(&mut self, mut item: MissingPicture) { for name in [&mut item.part, &mut item.story] { diff --git a/crates/docx/src/read.rs b/crates/docx/src/read.rs index 95631b1f44..f8570c1089 100644 --- a/crates/docx/src/read.rs +++ b/crates/docx/src/read.rs @@ -105,7 +105,7 @@ pub(crate) struct Reader<'p> { /// The main document part's name. main: String, /// The part being read (`word/header2.xml`), for naming where a dropped picture was. - story: String, + story: Arc, /// The package's content types, read when first needed. content_types: Option, /// Parts kept for charts, diagrams and OLE objects (see `embed`). @@ -186,7 +186,7 @@ fn read_opened_in(pkg: &Package, chunks: ChunkState) -> Result<(Document, usize, glued: HashSet::new(), shape_ids: false, main: main.clone(), - story: main.clone(), + story: main.as_str().into(), content_types: None, embedded: EmbeddedManifest::default(), txbx_links: Vec::new(), @@ -235,18 +235,18 @@ fn read_opened_in(pkg: &Package, chunks: ChunkState) -> Result<(Document, usize, let ex = part(rt::COMMENTS_EX); let ids = part(rt::COMMENTS_IDS); let cex = part(rt::COMMENTS_EXTENSIBLE); - r.story = p.clone(); + r.story = p.as_str().into(); lenient("comments", r.read_comments(&p, ex.as_deref(), ids.as_deref(), cex.as_deref())); } if let Some(p) = part(rt::FOOTNOTES) { - r.story = p.clone(); + r.story = p.as_str().into(); lenient("footnotes", r.read_notes(&p, true)); } if let Some(p) = part(rt::ENDNOTES) { - r.story = p.clone(); + r.story = p.as_str().into(); lenient("endnotes", r.read_notes(&p, false)); } - r.story = main.clone(); + r.story = main.as_str().into(); // Bibliography sources live in a custom XML data store (`b:Sources`), related from the // package root or the main part; Word's own files sometimes leave the relationship out, so // the conventional `customXml/itemN.xml` names are tried too. Each part is read at most once. diff --git a/crates/docx/src/read/chunk.rs b/crates/docx/src/read/chunk.rs index 5ba1a738c6..937d8c615f 100644 --- a/crates/docx/src/read/chunk.rs +++ b/crates/docx/src/read/chunk.rs @@ -93,7 +93,7 @@ impl Reader<'_> { // The nested records past its list are the file's too; their stories carry the chunk's // part, so none is one the outer list already counted. let unlisted = nested.total.saturating_sub(list_len); - self.doc.missing_pictures.total = self.doc.missing_pictures.total.saturating_add(unlisted); + self.doc.missing_pictures.add_unlisted(&part, unlisted); self.doc.missing_pictures.truncated |= nested.truncated; let mut blocks = self.doc.adopt_blocks(doc, keep, &mut self.next_perm); // Bookmarks and comment marks read before the chunk stay before it. diff --git a/crates/docx/src/read/glossary.rs b/crates/docx/src/read/glossary.rs index 5aa6b7192e..bc6b8dd54a 100644 --- a/crates/docx/src/read/glossary.rs +++ b/crates/docx/src/read/glossary.rs @@ -54,7 +54,7 @@ impl Reader<'_> { if let Some(root) = root.filter(|r| r.name == "w:glossaryDocument" && ct.as_deref().is_none_or(|t| t == GLOSSARY_CONTENT_TYPE)) { let rels = self.pkg.rels(&rel.target); // Pictures in a building block belong to the glossary part, not the main story. - let outer = std::mem::replace(&mut self.story, rel.target.clone()); + let outer = std::mem::replace(&mut self.story, rel.target.as_str().into()); entries = self.doc_parts(&root, &rels); self.story = outer; } diff --git a/crates/docx/src/read/parts.rs b/crates/docx/src/read/parts.rs index 71c7d19419..565e65c2a5 100644 --- a/crates/docx/src/read/parts.rs +++ b/crates/docx/src/read/parts.rs @@ -128,7 +128,7 @@ impl Reader<'_> { /// Remember a picture that was dropped at open, in the story being read. pub(super) fn note_missing_picture(&mut self, part: &str, reason: MissingPictureReason) { let part: String = part.chars().take(1024).collect(); - let story = self.story.clone(); + let story = self.story.to_string(); self.doc.missing_pictures.push(MissingPicture { part, story, reason }); } @@ -153,7 +153,8 @@ impl Reader<'_> { let kind = if footer { PartKind::Footer } else { PartKind::Header }; let id = self.doc.add_part(kind, Blocks::new()); self.hf_by_path.insert(path.to_string(), id); - self.doc.missing_pictures.part_names.insert(id, path.to_string()); + let name: Arc = path.into(); + self.doc.missing_pictures.part_names.insert(id, Arc::clone(&name)); let root = match self.xml(path) { Ok(Some(r)) => r, _ => return Some(id), @@ -163,7 +164,7 @@ impl Reader<'_> { sc.in_hf = true; let mut blocks = Blocks::new(); self.hf_depth += 1; - let outer = std::mem::replace(&mut self.story, path.to_string()); + let outer = std::mem::replace(&mut self.story, name); self.read_blocks(&mut sc, &root, &rels, &mut blocks, self.chunks.depth); self.story = outer; self.hf_depth = self.hf_depth.saturating_sub(1); diff --git a/crates/docx/tests/it/alt_chunks.rs b/crates/docx/tests/it/alt_chunks.rs index c13bfddc3e..5b3ab7e136 100644 --- a/crates/docx/tests/it/alt_chunks.rs +++ b/crates/docx/tests/it/alt_chunks.rs @@ -433,3 +433,13 @@ fn a_chunks_total_of_dropped_pictures_counts_past_the_list() { assert!(d.missing_pictures.truncated); assert_eq!(d.missing_pictures.total, n as u64); } + +/// The same chunk imported twice counts its unlisted pictures once, as it dedupes its listed ones. +#[test] +fn a_chunk_imported_twice_counts_its_dropped_pictures_past_the_list_once() { + let n = wordcraft_doc::MAX_MISSING_PICTURES + 44; + let inner = inner_with_dropped_pictures(n, false); + let d = read(&package(&format!("{}{CHUNK}{CHUNK}", p("Before")), Some(("docx", DOCX_MAIN, &inner)))).unwrap(); + assert_eq!(d.missing_pictures.list.len(), wordcraft_doc::MAX_MISSING_PICTURES); + assert_eq!(d.missing_pictures.total, n as u64); +} diff --git a/crates/docx/tests/it/missing_pictures.rs b/crates/docx/tests/it/missing_pictures.rs index 03a00b8c88..a7b065c71c 100644 --- a/crates/docx/tests/it/missing_pictures.rs +++ b/crates/docx/tests/it/missing_pictures.rs @@ -98,7 +98,7 @@ fn a_header_pictures_story_is_the_header_part() { let d = wordcraft_docx::read(&bytes).unwrap(); assert_eq!(missing(&d), [("https://example.invalid/logo.png".into(), "word/header2.xml".into(), MissingPictureReason::ExternalTarget)]); assert_eq!(d.missing_pictures.main, "word/document.xml"); - assert!(d.missing_pictures.part_names.values().any(|n| n == "word/header2.xml")); + assert!(d.missing_pictures.part_names.values().any(|n| &**n == "word/header2.xml")); assert_eq!(d.missing_pictures.media_parts.get("ok.png").map(String::as_str), Some("word/media/ok.png")); } @@ -147,3 +147,17 @@ fn the_list_is_capped_and_the_document_is_still_equal() { assert_eq!(d, e); assert!(!serde_json::to_string(&d).unwrap().contains("gone")); } + +/// Every note of a part shares the one allocation of its source name, so a file with many notes +/// in a part with a long name does not keep a copy of the name per note. +#[test] +fn the_notes_of_a_part_share_one_copy_of_its_name() { + let notes: String = (1..=3).map(|i| format!(r#"n{i}"#)).collect(); + let rels = format!(r#""#); + let part = format!(r#"{notes}"#); + let d = wordcraft_docx::read(&package("x", &rels, None, &[("word/footnotes.xml", part.into_bytes())])).unwrap(); + let names: Vec<_> = d.missing_pictures.part_names.values().collect(); + assert!(names.len() >= 3, "{names:?}"); + assert!(names.iter().all(|n| &***n == "word/footnotes.xml")); + assert!(names.windows(2).all(|w| std::sync::Arc::ptr_eq(w[0], w[1])), "each note holds its own copy of the part name"); +} diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index f3f1b45c41..86e2664285 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -84,7 +84,7 @@ use headers::MAX_HEADER_ITEMS; #[cfg(test)] use headers::placed_cost; use headers::placed_cost_by; -pub use missing::{MAX_MISSING_GLYPHS, MissingGlyphs, missing_watermark_glyphs, watermark_run}; +pub use missing::{MAX_MISSING_GLYPHS, MissingGlyphs, metafile_text_run, missing_watermark_glyphs, watermark_run}; pub use notes::DECOR_STORY; pub(crate) use notes::NOTE_SEP_W; use notes::{MAX_NOTE_PASSES, footnote_pages}; diff --git a/crates/layout/src/missing.rs b/crates/layout/src/missing.rs index e1a6402d70..6ea5c3bc20 100644 --- a/crates/layout/src/missing.rs +++ b/crates/layout/src/missing.rs @@ -2,7 +2,7 @@ //! id 0 in a [`Draw::Glyphs`] of the display list, traced back to its character through the //! run's `text` and per-glyph `ranges`. Backs strict export (#6005). -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use std::ops::{Range, RangeInclusive}; use wordcraft_doc::Document; @@ -121,7 +121,11 @@ impl DocLayout { continue; } for d in page_display(doc, page, opts) { - found.walk(&d); + found.walk(doc, &d); + } + // The page picture is drawn behind every page, outside the display list. + if let Some(pic) = &doc.settings.page_picture { + found.metafile_text(doc, &pic.media); } } found.finish() @@ -143,6 +147,16 @@ pub fn watermark_run(wm: &wordcraft_doc::Watermark, max_chars: usize) -> Option< Some((face, text, shaped)) } +/// How a metafile text run is shaped for output, as the PDF export and the page raster both do: +/// in the face its font name resolves to (no fallback face), so a character that face lacks shapes +/// to glyph id 0. Strict export checks these same glyphs, so the check and the exporters can't +/// drift. +pub fn metafile_text_run(t: &wordcraft_metafile::Text) -> (wordcraft_fonts::FaceRef, Vec) { + let face = wordcraft_fonts::word::resolve(&t.font, t.bold, t.italic).face; + let shaped = wordcraft_fonts::shape(&face, &t.text, &[], |c| c); + (face, shaped) +} + /// The characters of the watermark that draw as an empty box (glyph id 0), counted as /// [`DocLayout::missing_glyphs`] counts them; sorted by code point. The watermark is on every /// page, so it counts once. @@ -157,24 +171,49 @@ pub fn missing_watermark_glyphs(wm: &wordcraft_doc::Watermark, max_chars: usize) #[derive(Default)] struct Found { + /// The text runs of each metafile picture met, by media key, shaped once: (face, text, ranges, glyph ids). + metafiles: HashMap>, counts: BTreeMap, truncated: bool, /// Every distinct code point, started when the list passes its cap. over: Option>, } +/// A metafile text run as drawn: its face, text, per-glyph ranges and glyph ids. +struct MetaRun { + face: wordcraft_fonts::FaceRef, + text: String, + ranges: Vec>, + gids: Vec, +} + impl Found { + /// The text of the metafile picture `key` (nothing for a picture that is not a metafile, does + /// not parse or has no size: the exporters draw none of it), counted once per call. + fn metafile_text(&mut self, doc: &Document, key: &str) { + if !self.metafiles.contains_key(key) { + let runs = doc.media.get(key).map(|bytes| meta_runs(bytes)).unwrap_or_default(); + self.metafiles.insert(key.to_string(), runs); + } + let Some(runs) = self.metafiles.remove(key) else { return }; + for r in &runs { + self.run(&r.face, r.gids.iter().copied(), &r.text, &r.ranges); + } + self.metafiles.insert(key.to_string(), runs); + } + fn finish(self) -> MissingGlyphs { let total = self.over.as_ref().map_or(self.counts.len(), |s| s.len()); MissingGlyphs { chars: self.counts.into_iter().collect(), truncated: self.truncated, total, seen: self.over } } - fn walk(&mut self, d: &Draw) { + fn walk(&mut self, doc: &Document, d: &Draw) { match d { Draw::Glyphs { face, glyphs, text, ranges, .. } => self.run(face, glyphs.iter().map(|g| g.0), text, ranges), + Draw::Image { media, .. } => self.metafile_text(doc, media), Draw::Figure { draws: items, .. } | Draw::Turned { items, .. } | Draw::Rotated { items, .. } | Draw::Clip { items, .. } => { for d in items { - self.walk(d); + self.walk(doc, d); } } _ => {} @@ -224,6 +263,29 @@ impl Found { } } +/// The text runs of a metafile picture that the exporters draw (a run whose layout fails is not). +fn meta_runs(bytes: &[u8]) -> Vec { + if !wordcraft_metafile::is_metafile(bytes) { + return Vec::new(); + } + let Ok(pic) = wordcraft_metafile::parse(bytes) else { return Vec::new() }; + if !pic.has_size() { + return Vec::new(); + } + let mut out = Vec::new(); + for item in &pic.items { + let wordcraft_metafile::Item::Text(t) = item else { continue }; + let (face, shaped) = metafile_text_run(t); + let glyphs: Vec<_> = shaped.iter().map(|g| (g.cluster, f64::from(g.x_advance), f64::from(g.x_offset), f64::from(g.y_offset))).collect(); + if t.layout(face.upem, face.ascent, face.descent, &glyphs).is_none() { + continue; + } + let ranges = cluster_ranges(&t.text, &shaped); + out.push(MetaRun { gids: shaped.iter().map(|g| g.gid).collect(), text: t.text.clone(), ranges, face }); + } + out +} + /// The byte range of `text` each of `shaped` shows: from its cluster to the next cluster start in /// the text (or its end), whichever order the glyphs are in. A ligature's glyph covers all its /// characters; the glyphs of one cluster share its range. Ranges always lie on character diff --git a/crates/layout/src/strict.rs b/crates/layout/src/strict.rs index cac204b077..5c9608a9ec 100644 --- a/crates/layout/src/strict.rs +++ b/crates/layout/src/strict.rs @@ -163,7 +163,7 @@ fn collect_media(draws: &[Draw], out: &mut Vec) { /// The story a placed item is in, named as [`undecodable_pictures`] says. fn story_name(doc: &Document, region: Region, it: &Placed) -> String { let names = &doc.missing_pictures; - let known = |id: Option| id.and_then(|id| names.part_names.get(&id)).cloned(); + let known = |id: Option| id.and_then(|id| names.part_names.get(&id)).map(|n| n.to_string()); match region { Region::Header(id) => known(id).unwrap_or_else(|| "header".into()), Region::Footer(id) => known(id).unwrap_or_else(|| "footer".into()), diff --git a/crates/pdf/src/exporter/text.rs b/crates/pdf/src/exporter/text.rs index 74c15fc763..d1eeeb74b3 100644 --- a/crates/pdf/src/exporter/text.rs +++ b/crates/pdf/src/exporter/text.rs @@ -183,8 +183,7 @@ impl Exporter<'_> { /// Draws a metafile text run as PDF text (outlines when the font cannot be embedded). `tf` maps /// the run's own space (see [`PlacedItem::Text`]) to the page. pub(crate) fn vector_text(&mut self, s: &mut Surface, t: &wordcraft_metafile::Text, tf: kurbo::Affine) { - let face = wordcraft_fonts::word::resolve(&t.font, t.bold, t.italic).face; - let shaped = wordcraft_fonts::shape(&face, &t.text, &[], |c| c); + let (face, shaped) = wordcraft_layout::metafile_text_run(t); let glyphs: Vec<_> = shaped.iter().map(|g| (g.cluster, f64::from(g.x_advance), f64::from(g.x_offset), f64::from(g.y_offset))).collect(); let Some(lay) = t.layout(face.upem, face.ascent, face.descent, &glyphs) else { return }; let font = self.font(&face); diff --git a/crates/render/src/media.rs b/crates/render/src/media.rs index 9b8e24835e..bb77e71f27 100644 --- a/crates/render/src/media.rs +++ b/crates/render/src/media.rs @@ -254,8 +254,7 @@ pub(crate) fn draw_vector(ctx: &mut RenderContext, v: &Vector, r: &kurbo::Rect, /// Draws a metafile text run: `tf` maps the run's own space (see [`PlacedItem::Text`]) to the device. fn draw_vector_text(ctx: &mut RenderContext, t: &wordcraft_metafile::Text, tf: Affine) { - let face = wordcraft_fonts::word::resolve(&t.font, t.bold, t.italic).face; - let shaped = wordcraft_fonts::shape(&face, &t.text, &[], |c| c); + let (face, shaped) = wordcraft_layout::metafile_text_run(t); let glyphs: Vec<_> = shaped.iter().map(|g| (g.cluster, f64::from(g.x_advance), f64::from(g.x_offset), f64::from(g.y_offset))).collect(); let Some(lay) = t.layout(face.upem, face.ascent, face.descent, &glyphs) else { return }; let k = lay.em / face.upem.max(1.0);