From adad531adb4e0cd34442e3f1d8b80d6de5b19f5e Mon Sep 17 00:00:00 2001 From: redrun6767 Date: Sat, 10 Oct 2026 18:23:07 +0000 Subject: [PATCH 1/2] PDF import: a font the file doesn't carry keeps the style its name says A PDF that names a font without embedding it (here a standard Helvetica) is drawn with a stand-in, so comparing its glyphs with the installed family's styles picks whichever style happens to look most like the stand-in: with a large Helvetica family installed, Helvetica became Helvetica CE Medium (tests_import_fidelity::text_becomes_point_type failed on such a Mac). Fonts without a font program now keep the face their name resolves to; embedded fonts are still compared as before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CC5DiGJEZ5ADaQMBEQk4W1 --- crates/pdf/src/import.rs | 16 +++++++++++----- crates/pdf/src/import_scan.rs | 29 ++++++++++++++++++++++++----- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index 7a5b3ab8..a8a39eba 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -21,7 +21,7 @@ use vectorcraft_geom::{FillRule, PathData}; use crate::import_color::{Colors, Native}; use crate::import_mask::{MaskSpec, contains, is_rectangle, luminance, mask_spec, white_cover}; -use crate::import_scan::{MAX_NESTING, Ocgs, Scan, all_on, hides_forms, scan_page, tag_key}; +use crate::import_scan::{FontNames, MAX_NESTING, Ocgs, Scan, all_on, hides_forms, scan_page, tag_key}; use crate::import_shading::{clipped, extend_clip, fold_stop_opacity, mesh_shading, shading_gradient}; use crate::import_text::{Families, LineFacts, Look, Placement, TextLine, Upright}; use crate::{CropTo, ImportOptions, ImportReport, PdfError, TextAs}; @@ -692,8 +692,9 @@ struct Builder<'p> { /// Mask keys of CMYK images whose mask turned out to hide nothing. opaque: HashSet, warnings: Vec, - /// Fonts by cache key → base font name (from [`scan_page`]). - fonts: HashMap, + /// Fonts by cache key → base font name and whether the file carries its glyphs (from + /// [`scan_page`]). + fonts: FontNames, font_names: HashMap, families: Option, missing_fonts: Vec, @@ -1346,7 +1347,7 @@ impl<'p> Builder<'p> { return n.clone(); } let data = o.font_data(); - let name = self.fonts.get(&key).cloned().or_else(|| data.as_ref().and_then(|d| d.postscript_name.clone())); + let name = self.fonts.get(&key).map(|(n, _)| n.clone()).or_else(|| data.as_ref().and_then(|d| d.postscript_name.clone())); let (weight, italic) = data.as_ref().map_or((None, false), |d| (d.weight, d.is_italic)); // A subset's six-letter tag. let ps = name.as_deref().map(|n| match n.split_once('+') { @@ -1429,7 +1430,12 @@ impl<'p> Builder<'p> { let upright = width.is_some_and(|w| w.abs() <= 1.0); let top = m * kurbo::Point::new(f64::from(width.filter(|w| *w > 1.0).unwrap_or(1000.0)) * 0.5, 880.0); let mut info = self.font_name(key, o); - if let Some(face) = &info.face { + // A font the file doesn't carry is drawn with a stand-in: its glyphs can't tell which + // style of the installed family it is, so the name decides. + let embedded = self.fonts.get(&key).is_none_or(|(_, e)| *e); + if let Some(face) = &info.face + && embedded + { let decided = match self.matched.get(&key) { Some(m) => m.clone(), // A glyph that can't be compared (several characters, a vertical form) decides diff --git a/crates/pdf/src/import_scan.rs b/crates/pdf/src/import_scan.rs index d2ec2e81..667274e8 100644 --- a/crates/pdf/src/import_scan.rs +++ b/crates/pdf/src/import_scan.rs @@ -248,7 +248,7 @@ struct Walker<'w, 'p> { /// The groups that are off for the interpreter (none once [`all_on`] turned them on). off: HashSet, xref: &'p hayro_syntax::xref::XRef, - fonts: &'w mut HashMap, + fonts: &'w mut FontNames, visible: Vec, ops: usize, max_ops: usize, @@ -296,7 +296,7 @@ impl Walker<'_, '_> { if let Some(f) = res.fonts.get::>(key.as_ref()) && let Some(name) = f.get::>(b"BaseFont") { - self.fonts.entry(f.cache_key()).or_insert_with(|| String::from_utf8_lossy(name.as_ref()).into_owned()); + self.fonts.entry(f.cache_key()).or_insert_with(|| (String::from_utf8_lossy(name.as_ref()).into_owned(), embeds_glyphs(&f))); } } } @@ -372,13 +372,32 @@ impl Walker<'_, '_> { } } +/// The fonts of a document by cache key: base font name, and whether the file carries the glyphs +/// ([`embeds_glyphs`]). +pub(crate) type FontNames = HashMap; + +/// Whether font `f` carries its own glyphs: a font program in its descriptor (a composite font's in +/// its descendant's), or a Type 3 font's glyph procedures. A font that doesn't is drawn with a +/// stand-in, whose glyphs say nothing about which installed face the document means. +fn embeds_glyphs(f: &Dict<'_>) -> bool { + if f.get::>(b"Subtype").is_some_and(|s| s.as_ref() == b"Type3") { + return true; + } + // A font program is a stream, so always an indirect object. + let program = |d: &Dict<'_>| { + d.get::>(b"FontDescriptor").is_some_and(|fd| [&b"FontFile"[..], b"FontFile2", b"FontFile3"].iter().any(|k| fd.get_ref(*k).is_some())) + }; + program(f) || f.get::>(b"DescendantFonts").and_then(|a| a.iter::>().next()).is_some_and(|d| program(&d)) +} + /// Walk `page` as the interpreter will: `all_on` when [`all_on`] turned every group on. Fonts -/// found are noted in `fonts` (their cache key → base font name). -pub(crate) fn scan_page(page: &Page<'_>, ocgs: &mut Ocgs, all_on: bool, fonts: &mut HashMap) -> Scan { +/// found are noted in `fonts` (their cache key → base font name, and whether the file carries its +/// glyphs). +pub(crate) fn scan_page(page: &Page<'_>, ocgs: &mut Ocgs, all_on: bool, fonts: &mut FontNames) -> Scan { walk_page(page, ocgs, all_on, fonts).out } -fn walk_page<'w, 'p>(page: &Page<'p>, ocgs: &'w mut Ocgs, all_on: bool, fonts: &'w mut HashMap) -> Walker<'w, 'p> { +fn walk_page<'w, 'p>(page: &Page<'p>, ocgs: &'w mut Ocgs, all_on: bool, fonts: &'w mut FontNames) -> Walker<'w, 'p> { let off = if all_on { HashSet::new() } else { ocgs.off.clone() }; let mut w = Walker { ocgs, off, xref: page.xref(), fonts, visible: vec![], ops: 0, max_ops: max_ops(), out: Scan::default(), hidden_form: false }; w.walk(page.typed_operations(), page.resources(), 0); From 04e714e9ddfe3c7469db754ed6e204b2205e731a Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 04:14:45 +0700 Subject: [PATCH 2/2] ROADMAP: unembedded PDF fonts keep their named style (#1023) Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index c5c63136..510f88f5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap | Date | What landed | |---|---| -| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). | +| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). | | 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) | | 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day | | 2026-10-08 | v0.5.0–v0.7.0; community contributions: Pen and shape-tool modifiers, Layers, Artboards, Japanese composition, Hebrew/Arabic type, interface languages, saved selections |