From 0cd82d8d7dbed9aaeef229ddcb4ac1194827ed9e Mon Sep 17 00:00:00 2001 From: ham9zah Date: Thu, 29 Oct 2026 10:37:09 +0300 Subject: [PATCH] Import: the page's type of a right-to-left or turned label is its text object's A file's PDF part draws a right-to-left paragraph's words in the order they show ("mm 120.5"), while its text object keeps them in the order they are typed (" 120.5 mm "). The page's type was matched to the text objects by their letters in order, so it matched none and stayed beside the label as a second copy. It is now matched word by word. A point type label turned a quarter (a vertical dimension's) lay outside the box its text object was looked for in, which was always level; that box now runs along the type's baseline. --- crates/eps/src/import/ate.rs | 33 +++++++++++++++++++++++++-- crates/eps/src/import/native/plan.rs | 30 ++++++++++++------------ crates/eps/src/import/native/slots.rs | 7 ++++++ crates/eps/src/import/tests_native.rs | 19 +++++++++++++++ 4 files changed, 72 insertions(+), 17 deletions(-) diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index ff5b2906e..7c90b15aa 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -618,7 +618,7 @@ impl Story { let r = match self.frames.first()? { FrameKind::Area(s) => bounds(s)?, FrameKind::OnPath { segments, .. } => bounds(segments)?.inflate(size * 1.5, size * 1.5), - FrameKind::Point(_) => { + FrameKind::Point(m) => { let longest = self.text.split(['\r', '\x03']).map(|l| l.chars().count()).max().unwrap_or(0) as f64; let reach = longest * size + size; let points: Vec = self.line_starts(template).into_iter().map(|(_, (x, y))| to_doc * Point::new(x, y)).collect(); @@ -626,7 +626,19 @@ impl Story { let (x, y) = self.place(template); to_doc * Point::new(x, y) }); - points.iter().fold(Rect::from_points(first, first), |r, p| r.union_pt(*p)).inflate(reach, size * 1.5) + // Along the baseline as far as a line reaches, across it a line and a half: turned + // with the type (a dimension's label turned a quarter runs down the page). + // (The canvas is y down, the art y up: see [`to_art`].) + let along = to_doc * Point::new(m[0], -m[1]) - to_doc * Point::ZERO; + let along = if along.hypot() > 1e-9 && along.is_finite() { along / along.hypot() } else { kurbo::Vec2::new(1.0, 0.0) }; + let across = kurbo::Vec2::new(-along.y, along.x); + let mut r = Rect::from_points(first, first); + for p in points.iter().chain([&first]) { + for (t, n) in [(-reach, -1.5), (-reach, 1.5), (reach, -1.5), (reach, 1.5)] { + r = r.union_pt(*p + along * t + across * (n * size)); + } + } + r } }; r.is_finite().then_some(r) @@ -841,6 +853,23 @@ mod tests { assert_eq!(justify(b"/0 1 /33 0"), Justify::Right); } + /// Where point type lies runs along its baseline: a label turned a quarter (as a vertical + /// dimension's is) lies in a tall box, not a wide one. + #[test] + fn point_type_lies_along_its_baseline() { + let para = + Texts { stories: vec![], fonts: vec![], defaults: Val::Dict(BTreeMap::new()), para_defaults: Val::Dict(BTreeMap::new()), frames: vec![] } + .para(None); + let mut s = story(" 80 mm ", &[0.0], ¶); + let size = |s: &Story| s.region((0.0, 0.0), Affine::IDENTITY).map(|r| (r.width(), r.height())).unwrap(); + let (w, h) = size(&s); + assert!(w > 3.0 * h, "level: {w} × {h}"); + // Turned −90° on the canvas (y down): the baseline runs down the page. + s.frames = vec![FrameKind::Point([0.0, 1.0, -1.0, 0.0, 0.0, 0.0])]; + let (w, h) = size(&s); + assert!(h > 3.0 * w, "turned: {w} × {h}"); + } + /// A right-to-left paragraph is laid out right to left even when it has no right-to-left /// letters (a dimension's label " 120.5 mm " reads "mm 120.5"); a left-to-right one, or one /// that doesn't say, goes by its letters. diff --git a/crates/eps/src/import/native/plan.rs b/crates/eps/src/import/native/plan.rs index a89ad0dd9..cc2772f8e 100644 --- a/crates/eps/src/import/native/plan.rs +++ b/crates/eps/src/import/native/plan.rs @@ -1,5 +1,5 @@ use super::layers::closed_where_ends_meet; -use super::slots::{Content, SlotInfo, assign, covered, extent, join_pieces, letters, same_type}; +use super::slots::{Content, SlotInfo, assign, covered, extent, join_pieces, letters, same_type, words}; use super::{Affine, Arc, Node, NodeId, NodeKind, Point, Rect, Story, TextKind}; /// Match page objects to the nearest slot whose story describes them, including hidden slots. @@ -103,28 +103,28 @@ pub(super) fn plan( // with its words? (A copied object's lines are where its original's are, so they don't say.) let hidden_type = |object: &[Arc]| { let Some(r) = extent(object) else { return false }; - let words: Vec = - object.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(letters(&t.plain_text())) } else { None }).collect(); + let texts: Vec> = + object.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(words(&t.plain_text())) } else { None }).collect(); hidden_at.iter().any(|(k, place)| { place.intersect(r).area() >= 0.5 * r.area().max(1e-9) - && lines - .get(*k) - .and_then(Option::as_ref) - .is_some_and(|l| !words.is_empty() && words.iter().all(|w| l.iter().any(|(text, _)| letters(text).contains(w.as_str())))) + && lines.get(*k).and_then(Option::as_ref).is_some_and(|l| { + !texts.is_empty() + && texts.iter().all(|ws| !ws.is_empty() && ws.iter().all(|w| l.iter().any(|(text, _)| letters(text).contains(w.as_str())))) + }) }) }; // The letters of each slot's story, in lower case. let spelled: Vec = stories.iter().map(|s| s.as_ref().map(|s| letters(s.text()).to_ascii_lowercase()).unwrap_or_default()).collect(); - // For each slot: is its made type where `object` is, with the letters of one of `object`'s texts in - // its story? All are false unless such slots have the letters of every text between them. + // For each slot: is its made type where `object` is, with the words of one of `object`'s texts in + // its story? All are false unless such slots have the words of every text between them. let spelling = |object: &[Arc], content: &[Content]| -> Vec { let r = extent(object); - let words: Vec = object + let texts: Vec> = object .iter() - .filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(letters(&t.plain_text()).to_ascii_lowercase()) } else { None }) - .filter(|w| !w.is_empty()) + .filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(words(&t.plain_text().to_ascii_lowercase())) } else { None }) + .filter(|ws| !ws.is_empty()) .collect(); - let has = |k: usize| spelled.get(k).is_some_and(|s| words.is_empty() || words.iter().any(|w| s.contains(w.as_str()))); + let has = |k: usize| spelled.get(k).is_some_and(|s| texts.is_empty() || texts.iter().any(|ws| ws.iter().all(|w| s.contains(w.as_str())))); let near: Vec = content .iter() .enumerate() @@ -133,7 +133,7 @@ pub(super) fn plan( _ => false, }) .collect(); - let every = words.iter().all(|w| near.iter().zip(&spelled).any(|(n, s)| *n && s.contains(w.as_str()))); + let every = texts.iter().flatten().all(|w| near.iter().zip(&spelled).any(|(n, s)| *n && s.contains(w.as_str()))); if every { near } else { vec![] } }; let mut spare: Vec>> = vec![]; @@ -150,7 +150,7 @@ pub(super) fn plan( Some(c) => *c = Content::Page(object.clone()), None => {} }, - // What lies where made type is and has that type's letters is that type as the page draws it. + // What lies where made type is and has that type's words is that type as the page draws it. None if covered(object, &content, spells) => { let r = extent(object).unwrap_or(Rect::ZERO); for (k, c) in content.iter_mut().enumerate() { diff --git a/crates/eps/src/import/native/slots.rs b/crates/eps/src/import/native/slots.rs index 524fbcc81..1548a2e9a 100644 --- a/crates/eps/src/import/native/slots.rs +++ b/crates/eps/src/import/native/slots.rs @@ -323,6 +323,13 @@ pub(super) fn letters(s: &str) -> String { s.chars().filter(char::is_ascii_alphanumeric).collect() } +/// The [`letters`] of each word of `s`, the empty ones left out. Type is compared word by word: +/// the page draws a right-to-left paragraph's words in the order they show (" 120.5 mm " as +/// "mm 120.5"), its text object keeps them in the order they are typed. +pub(super) fn words(s: &str) -> Vec { + s.split_whitespace().map(letters).filter(|w| !w.is_empty()).collect() +} + /// Does `n` hold type that is where `story` lays one of its lines out: the line, or a piece of it /// (the page splits a line where its kerning or its styles change), on that line's baseline and /// after its start? How far from where the line starts it is, if so. diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 7d804142c..c0fa58c22 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -779,6 +779,25 @@ fn type_of_the_page_with_the_letters_of_made_type_where_it_lies_is_that_type() { } } +/// A right-to-left paragraph's page type has its words in the order they show, not the order they +/// are typed (a dimension's label " 120.5 mm " is drawn "mm 120.5"): it is still that text +/// object's type as the page draws it, not a second copy of it with the nearest text object. +#[test] +fn type_of_the_page_with_the_words_of_made_type_in_another_order_is_that_type() { + let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); + let art = native_with_text(&layer("Art", true, &text_object(0)), &doc); + let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 58.5 moveto (there Hi) show"; + let r = import(&eps(page, &art)).unwrap(); + let mut all = vec![]; + r.document.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + all.push(t.plain_text()); + } + }); + assert_eq!(all, ["Hi there"], "{:?}", r.warnings); + assert!(!r.warnings.iter().any(|w| w.contains("with another's")), "{:?}", r.warnings); +} + /// Page type of no text object that lies where the made type of two text objects is, with the /// letters of one of them in each of its pieces, is the type of both as the page draws it. #[test]