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.
This commit is contained in:
ham9zah
2026-10-29 18:46:47 +07:00
committed by bflatastic
parent 2dcfbb0bf0
commit 0cd82d8d7d
4 changed files with 72 additions and 17 deletions
+31 -2
View File
@@ -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<Point> = 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], &para);
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.
+15 -15
View File
@@ -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<Node>]| {
let Some(r) = extent(object) else { return false };
let words: Vec<String> =
object.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(letters(&t.plain_text())) } else { None }).collect();
let texts: Vec<Vec<String>> =
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<String> = 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<Node>], content: &[Content]| -> Vec<bool> {
let r = extent(object);
let words: Vec<String> = object
let texts: Vec<Vec<String>> = 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<bool> = 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<Arc<Node>>> = 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() {
+7
View File
@@ -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<String> {
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.
+19
View File
@@ -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]