From 331fc48b246f316024a8dc8b41c53693b5214c12 Mon Sep 17 00:00:00 2001 From: Hiroshi Inukai Date: Tue, 13 Oct 2026 07:11:24 +0900 Subject: [PATCH 1/2] PDF/.ai import: type stroked glyph by glyph stays one object Type on a path is written glyph by glyph, and a stroke added to it as each glyph's outline stroked right after the glyph. The path in between ended the line, so each glyph came in as its own text object. Such a stroke, in the glyph's own paint, is now held with the line and written after it, where it becomes the type's stroke as before. Co-Authored-By: Claude Opus 5.5 # Conflicts: # crates/pdf/src/tests_import_lines.rs --- crates/pdf/src/import.rs | 51 +++++++++++++++++++++++++--- crates/pdf/src/import_text.rs | 11 +++++- crates/pdf/src/tests_import_lines.rs | 19 +++++++++++ 3 files changed, 75 insertions(+), 6 deletions(-) diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index 4af87211..70877028 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -1241,11 +1241,48 @@ impl<'p> Builder<'p> { } fn flush_text(&mut self) { - let Some((t, opacity, facts)) = self.text.take().and_then(TextLine::finish) else { return }; - let mut n = Node::new(self.id(), NodeKind::Text(Box::new(t))); - n.opacity = opacity; - self.lines.insert(n.id, facts); - self.push_node(n); + let Some(mut line) = self.text.take() else { return }; + let held = std::mem::take(&mut line.held); + if let Some((t, opacity, facts)) = line.finish() { + let mut n = Node::new(self.id(), NodeKind::Text(Box::new(t))); + n.opacity = opacity; + self.lines.insert(n.id, facts); + self.push_node(n); + } + // The strokes of its glyphs' outlines, right after it ([`import_lines`] makes them its stroke). + for n in held { + self.push_node(n); + } + } + + /// A stroke of the outline of the glyph just drawn, in the glyph's own paint, while every glyph + /// before it on the open line was stroked so too, is held with the line instead of ending it + /// (an editor writes type on a path glyph by glyph, each followed by the stroke added to the + /// type). `false`: it isn't one. + fn hold_glyph_stroke(&mut self, path: &BezPath, transform: Affine, paint: &hayro_interpret::Paint<'_>, draw_mode: &PathDrawMode) -> bool { + let PathDrawMode::Stroke(props) = draw_mode else { return false }; + if self.mask.is_some() || self.blend != BlendMode::Normal || matches!(paint, hayro_interpret::Paint::Pattern(_)) { + return false; + } + let (p, opacity) = self.paint(paint, true); + let Some(line) = &self.text else { return false }; + let Some(ink) = line.ink.last() else { return false }; + if line.held.len() + 1 != line.ink.len() || opacity < 0.999 || !line.last_fill().is_some_and(|f| *f == p) { + return false; + } + let mut bp = path.clone(); + bp.apply_affine(transform); + let b = bp.bounding_box(); + let tol = 0.01 * (ink.width() + ink.height()) + 0.01; + if ![(b.x0, ink.x0), (b.y0, ink.y0), (b.x1, ink.x1), (b.y1, ink.y1)].iter().all(|(a, c)| (a - c).abs() <= tol) { + return false; + } + let st = stroke_layer(p, opacity, props, mean_scale(transform)); + let n = Node::path(self.id(), PathData::from_bezpath(&bp), Appearance { items: vec![AppearanceItem::Stroke(st)], ..Default::default() }); + if let Some(line) = &mut self.text { + line.held.push(n); + } + true } /// Convert a hayro paint to a VectorCraft paint and opacity (`stroke`: for a stroke). @@ -1667,6 +1704,10 @@ impl<'a> Device<'a> for Builder<'_> { fn draw_path(&mut self, path: &BezPath, transform: Affine, paint: &hayro_interpret::Paint<'a>, draw_mode: &PathDrawMode) { self.flush_glyphs(); + if self.hold_glyph_stroke(path, transform, paint, draw_mode) { + self.pending = false; + return; + } self.flush_text(); self.pending = false; let overprint = self.path_overprint(); diff --git a/crates/pdf/src/import_text.rs b/crates/pdf/src/import_text.rs index 1ccab798..04d25c0b 100644 --- a/crates/pdf/src/import_text.rs +++ b/crates/pdf/src/import_text.rs @@ -11,7 +11,7 @@ use std::collections::HashMap; use kurbo::{Affine, BezPath, Point, Rect, Vec2}; use vectorcraft_color::Paint; -use vectorcraft_doc::{CharStyle, ParaDirection, TextKind, TextObject, TextRun}; +use vectorcraft_doc::{CharStyle, Node, ParaDirection, TextKind, TextObject, TextRun}; use vectorcraft_text::TextLayout; /// One glyph's placement: its baseline origin, advance direction, size and horizontal scale. @@ -144,6 +144,9 @@ pub(crate) struct TextLine { gaps: Vec<(usize, f64)>, /// Each glyph's advance in the file's font (points), with [`Self::baseline`]. advances: Vec, + /// The strokes of its glyphs' outlines drawn between them, one per glyph with ink so far + /// (written right after the line). + pub held: Vec, } /// What a finished line of type tells beyond its text object (see [`crate::import_lines`]). @@ -177,9 +180,15 @@ impl TextLine { ink: vec![], gaps: vec![], advances: vec![], + held: vec![], } } + /// The paint the last glyph is filled with. + pub fn last_fill(&self) -> Option<&Paint> { + self.runs.last()?.0.fill.as_ref() + } + /// Add glyph `text` set vertically (`upright`) if it continues this column (or makes this /// line's single glyph a column): below the last one, in the same column. `false`: it starts /// another line. diff --git a/crates/pdf/src/tests_import_lines.rs b/crates/pdf/src/tests_import_lines.rs index ea2ea3ad..fddb4622 100644 --- a/crates/pdf/src/tests_import_lines.rs +++ b/crates/pdf/src/tests_import_lines.rs @@ -153,6 +153,25 @@ fn a_stroke_on_the_type_of_a_moved_ai_page_is_still_the_types_stroke() { assert!(matches!(art[1].appearance.items.as_slice(), [AppearanceItem::Stroke(_)]), "{:?}", art[1].appearance); } +#[test] +fn glyphs_each_followed_by_their_stroked_outline_stay_one_object() { + // Type on a path is written glyph by glyph: each glyph, then its outline stroked in the same + // colour (a shadow under the type). + let glyph = |c: char, x: f64, stroke: &str| format!("BT 0 0 1 rg /F1 24 Tf {x} 50 Td ({c}) Tj ET {stroke} 0.5 w {}", outline(c, x)); + let i_at = 10.0 + 0.722 * 24.0; + let d = import(&page(&(glyph('H', 10.0, "0 0 1 RG") + &glyph('i', i_at, "0 0 1 RG")))).unwrap(); + let art = d.layers[0].children().unwrap(); + assert_eq!(art.len(), 1, "one object: {art:?}"); + let NodeKind::Text(t) = &art[0].kind else { panic!("{:?}", art[0].kind) }; + assert_eq!(t.plain_text(), "Hi"); + let [AppearanceItem::Stroke(st)] = art[0].appearance.items.as_slice() else { panic!("{:?}", art[0].appearance) }; + assert_eq!(st.paint.color(), Some(Color::rgb(0.0, 0.0, 1.0))); + // A stroke of another colour drawn over each glyph in turn would also cover the next one's + // neighbour as one object's stroke: the glyphs stay as the file draws them. + let d = import(&page(&(glyph('H', 10.0, "1 1 1 RG") + &glyph('i', i_at, "1 1 1 RG")))).unwrap(); + assert_eq!(texts(&d).len(), 2); +} + /// A 300 × 200 pt page with `lines` of (x, y from the top, size, text) in Source Sans 3. fn source_sans(lines: &[(f32, f32, f32, &str)]) -> Vec { crate::tests_live_text::text_pdf(lines, false) From 95304ad7f637a8b996a6ff90a8f685c92bd62cbf Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 13 Oct 2026 10:29:47 +0700 Subject: [PATCH 2/2] PDF import: a held glyph stroke takes its turn in the page's overprint list --- crates/pdf/src/import.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index 70877028..1c4ca1b3 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -1277,7 +1277,9 @@ impl<'p> Builder<'p> { if ![(b.x0, ink.x0), (b.y0, ink.y0), (b.x1, ink.x1), (b.y1, ink.y1)].iter().all(|(a, c)| (a - c).abs() <= tol) { return false; } - let st = stroke_layer(p, opacity, props, mean_scale(transform)); + let mut st = stroke_layer(p, opacity, props, mean_scale(transform)); + // Every painted path takes its turn in the scan's overprint list, a held one too. + st.overprint = self.path_overprint(); let n = Node::path(self.id(), PathData::from_bezpath(&bp), Appearance { items: vec![AppearanceItem::Stroke(st)], ..Default::default() }); if let Some(line) = &mut self.text { line.held.push(n);