Files
vectorcraft/crates/pdf/src/import_lines.rs
T
bflatasticandClaude Opus 5.5 e63606826a Fix #508: PDF/.ai import keeps type strokes, wrapped lines and layer order
- Type left unfinished when a layer's marked content ends, and a clip that closes in a later layer,
  stay in their own layer (no stray "Page N" layer, no art moved to the next layer).
- Top-level layers stack as the pages mark them, so a layer whose art starts on a later page keeps its
  place; layers marked with no art come in empty.
- A stroke written as each glyph's outline stroked, right after (or before) the type, becomes the type
  object's stroke above (or below) its characters: the type stays one editable object.
- Spaces read from gaps wider than a space get their own tracking where even tracking would put the
  glyphs after them more than a tenth of an em off (barcode digit groups); lines are measured to their
  last visible glyph, so a wrapped line's trailing space no longer spreads into the tracking.
- Lines wrapped in a frame (each wrapped line ending in a space, one left edge, font, size and leading,
  with an optional paragraph spacing) come back as one area type object, left aligned or justified,
  only where laying it out breaks the lines exactly where the file does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:37:37 +07:00

255 lines
12 KiB
Rust

//! What a file's lines of type were, beyond what each line says on its own (#508). An editor
//! writes what PDF text can't say as more art next to the text, and the type it wrapped in a
//! frame as lines:
//! - a stroke added to a type object (above or below its characters) comes as the outline of each
//! glyph stroked, one path per glyph, right after the type (or right before it): it is the
//! type's stroke again, so the type stays one object;
//! - area type comes as a line of point type per line, each wrapped line ending in a space. Lines
//! in a row sharing a left edge, font, size and leading (and paragraph spacing) are one area
//! type object again, aligned left or justified, where laying its text out in the frame breaks
//! it where the file does: else, as where its font isn't installed, they stay lines.
use std::collections::HashMap;
use std::sync::Arc;
use kurbo::{Point, Rect, Shape};
use vectorcraft_color::BlendMode;
use vectorcraft_doc::{AppearanceItem, Justify, Node, NodeId, NodeKind, TextKind, TextObject, TextRun};
use vectorcraft_geom::shapes;
use crate::import_text::LineFacts;
/// Rebuild the type among `nodes` (and in their groups) from what its lines tell (`lines`).
pub(crate) fn rebuild(nodes: &mut Vec<Arc<Node>>, lines: &HashMap<NodeId, LineFacts>) {
if lines.is_empty() {
return;
}
for n in nodes.iter_mut() {
if n.children().is_some_and(|c| !c.is_empty())
&& let Some(children) = Arc::make_mut(n).children_mut()
{
rebuild(children, lines);
}
}
join_strokes(nodes, lines);
join_paragraphs(nodes, lines);
}
/// The stroke paths `nodes` draw when they are the outlines of the glyphs whose ink boxes are
/// `ink`, stroked one by one alike (no fill, mask or blending, fully opaque).
fn outline_stroke(nodes: &[Arc<Node>], ink: &[Rect]) -> Option<AppearanceItem> {
let first = nodes.first()?;
let [stroke @ AppearanceItem::Stroke(_)] = first.appearance.items.as_slice() else { return None };
let on_glyph = |n: &Node, r: &Rect| {
let tol = 0.01 * (r.width() + r.height()) + 0.01;
let NodeKind::Path { path, clipping: false, guide: false, live: None, .. } = &n.kind else { return false };
let b = path.to_bezpath().bounding_box();
n.appearance == first.appearance
&& n.mask.is_none()
&& n.opacity >= 0.999
&& n.blend == BlendMode::Normal
&& [(b.x0, r.x0), (b.y0, r.y0), (b.x1, r.x1), (b.y1, r.y1)].iter().all(|(a, b)| (a - b).abs() <= tol)
};
(nodes.len() == ink.len() && nodes.iter().zip(ink).all(|(n, r)| on_glyph(n, r))).then(|| stroke.clone())
}
/// Each type object among `nodes` whose glyphs' outlines are stroked by the paths right after it
/// (or right before it) takes that stroke above (or below) its characters, and the paths go.
fn join_strokes(nodes: &mut Vec<Arc<Node>>, lines: &HashMap<NodeId, LineFacts>) {
let mut i = 0;
while i < nodes.len() {
let ink = nodes
.get(i)
.filter(|n| matches!(n.kind, NodeKind::Text(_)) && n.appearance.items.is_empty())
.and_then(|n| lines.get(&n.id))
.map_or(&[][..], |f| f.ink.as_slice());
let k = ink.len();
if k > 0 {
if let Some(stroke) = nodes.get(i + 1..i + 1 + k).and_then(|after| outline_stroke(after, ink)) {
nodes.drain(i + 1..i + 1 + k);
if let Some(t) = nodes.get_mut(i) {
Arc::make_mut(t).appearance.items.push(stroke);
}
} else if let Some(stroke) = i.checked_sub(k).and_then(|from| nodes.get(from..i)).and_then(|before| outline_stroke(before, ink)) {
nodes.drain(i - k..i);
i -= k;
if let Some(t) = nodes.get_mut(i) {
let ap = &mut Arc::make_mut(t).appearance;
ap.items.insert(0, stroke);
ap.set_contents_at(1);
}
}
}
i += 1;
}
}
/// `n` as a line that can be part of area type: horizontal point type, with what its line told.
fn line<'a>(n: &'a Node, lines: &'a HashMap<NodeId, LineFacts>) -> Option<(&'a TextObject, &'a LineFacts)> {
let NodeKind::Text(t) = &n.kind else { return None };
let point = matches!(t.kind, TextKind::Point) && !t.vertical && n.mask.is_none() && !t.runs.is_empty();
point.then_some(())?;
Some((t, lines.get(&n.id)?))
}
/// Lines of type in a row that may be one area type object.
struct Block {
/// The step from a wrapped line's baseline to the next.
leading: f64,
/// The extra step after a line ending a paragraph.
space: f64,
/// Each line's baseline below the first (text space).
ys: Vec<f64>,
}
/// The lines from `nodes`' first on that share its left edge, angle, font and size, its look
/// (opacity, blending, appearance) and steps down by one leading after a wrapped line and by
/// another (the leading and paragraph spacing) after a line ending a paragraph: how many, and
/// the block they make unless none of them was wrapped (0 and `None`: the first isn't a line).
fn block(nodes: &[Arc<Node>], lines: &HashMap<NodeId, LineFacts>) -> (usize, Option<Block>) {
let Some((head, (t0, f0))) = nodes.first().and_then(|n| Some((n, line(n, lines)?))) else { return (0, None) };
let st = t0.first_style();
let size = st.size;
let inv = t0.xf.inverse();
let lin = |t: &TextObject| {
let [a, b, c, d, ..] = t.xf.as_coeffs();
[a, b, c, d]
};
let (mut ys, mut wraps) = (vec![0.0], f0.wraps);
let (mut leading, mut para): (Option<f64>, Option<f64>) = (None, None);
for n in nodes.iter().skip(1) {
let Some((t, f)) = line(n, lines) else { break };
let s = t.first_style();
let alike = n.opacity == head.opacity
&& n.blend == head.blend
&& n.appearance == head.appearance
&& s.font_family == st.font_family
&& s.font_style == st.font_style
&& (s.size - size).abs() <= size * 0.01
&& t.para == t0.para
&& lin(t).iter().zip(lin(t0)).all(|(a, b)| (a - b).abs() < 1e-6);
let at = inv * (t.xf * Point::ZERO);
let step = at.y - ys.last().copied().unwrap_or_default();
if !alike || at.x.abs() > size * 0.02 || !(step > size * 0.5 && step < size * 4.0) {
break;
}
let slot = if wraps { &mut leading } else { &mut para };
match *slot {
Some(s) if (s - step).abs() > size * 0.01 => break,
Some(_) => {}
None => *slot = Some(step),
}
ys.push(at.y);
wraps = f.wraps;
}
let count = ys.len();
let Some(leading) = leading else { return (count, None) };
let space = para.map_or(0.0, |p| p - leading);
(count, (space > -size * 0.01).then_some(Block { leading, space: space.max(0.0), ys }))
}
/// Lines `nodes` of block `b` as one area type object, aligned left or justified, when laying it
/// out breaks its lines where the file does.
fn area_type(nodes: &[Arc<Node>], lines: &HashMap<NodeId, LineFacts>, b: &Block) -> Option<TextObject> {
let texts: Vec<(&TextObject, &LineFacts)> = nodes.iter().map(|n| line(n, lines)).collect::<Option<_>>()?;
let (first, _) = texts.first()?;
let size = first.first_style().size;
// Each line's tracking (a line with spaces tracked apart was set by hand).
let trackings: Vec<f64> = texts
.iter()
.map(|(t, _)| {
let mut all = t.runs.iter().map(|r| r.style.tracking);
let one = all.next()?;
all.all(|x| x == one).then_some(one)
})
.collect::<Option<_>>()?;
let median = |mut v: Vec<f64>| {
v.sort_by(f64::total_cmp);
v.get(v.len() / 2).copied()
};
let wrapped: Vec<f64> = texts.iter().filter(|(_, f)| f.wraps).map(|(_, f)| f.length).collect();
let longest = |v: &[f64]| v.iter().copied().fold(0.0, f64::max);
let mut tries = vec![];
// Wrapped lines all as long: justified (a paragraph's last line aligned left), tracked as the
// lines that end paragraphs are (the others are spaced out).
let full = longest(&wrapped);
if wrapped.len() >= 2 && wrapped.iter().all(|l| (full - l).abs() <= size * 0.02) {
let ends: Vec<f64> = texts.iter().zip(&trackings).filter(|((_, f), _)| !f.wraps).map(|(_, t)| *t).collect();
let tracking = median(ends).or_else(|| trackings.iter().copied().reduce(f64::min))?;
tries.push((Justify::JustifyLeft, full, tracking));
}
let all: Vec<f64> = texts.iter().map(|(_, f)| f.length).collect();
tries.push((Justify::Left, longest(&all) + size * 0.02, median(trackings)?));
tries.into_iter().find_map(|(justify, width, tracking)| laid_out(&texts, b, justify, width, tracking))
}
/// The area type of lines `texts` (block `b`): `width` wide, aligned `justify`, its characters
/// tracked `tracking`; `None` when it doesn't lay out as the lines did.
fn laid_out(texts: &[(&TextObject, &LineFacts)], b: &Block, justify: Justify, width: f64, tracking: f64) -> Option<TextObject> {
let (first, _) = texts.first()?;
let size = first.first_style().size;
// Leading as set, unless it is Auto's (120% of the size).
let explicit =
(b.leading - size * 1.2).abs() > size * 1e-3 || texts.iter().any(|(t, _)| t.runs.iter().any(|r| (r.style.size - size).abs() > 1e-9));
let mut runs: Vec<TextRun> = vec![];
for (k, (t, f)) in texts.iter().enumerate() {
for r in &t.runs {
let mut style = r.style.clone();
style.tracking = tracking;
style.leading = explicit.then_some((b.leading * 1000.0).round() / 1000.0);
match runs.last_mut() {
Some(last) if last.style == style => last.text.push_str(&r.text),
_ => runs.push(TextRun { text: r.text.clone(), style }),
}
}
// A wrapped line's space, or the end of a paragraph.
if k + 1 < texts.len()
&& let Some(last) = runs.last_mut()
{
last.text.push(if f.wraps { ' ' } else { '\n' });
}
}
let mut t = (*first).clone();
t.runs = runs;
t.para.justify = justify;
t.para.space_before = b.space;
let last = b.ys.last().copied()?;
let db = vectorcraft_text::FontDb::global();
// The frame's top puts the first baseline where the file has it.
t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, width, last + b.leading.max(size) + size)) };
let top = -vectorcraft_text::layout(db, &t).lines.first()?.baseline;
t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, top, width, last + b.leading.max(size))) };
let laid = vectorcraft_text::layout(db, &t);
let plain = t.plain_text();
let same = !laid.overflow
&& laid.lines.len() == texts.len()
&& laid.lines.iter().zip(texts).zip(&b.ys).all(|((l, (line, f)), y)| {
let text = plain.get(l.start..l.end).unwrap_or_default();
text.trim() == line.plain_text().trim()
&& (l.baseline - y).abs() <= size * 0.05
&& (justify != Justify::JustifyLeft || !f.wraps || (l.x1 - f.length).abs() <= size * 0.05)
});
t.cached_bounds = Some(laid.bounds);
same.then_some(t)
}
/// Lines of point type in a row among `nodes` that were wrapped in a frame become one area type
/// object again (see [`area_type`]).
fn join_paragraphs(nodes: &mut Vec<Arc<Node>>, lines: &HashMap<NodeId, LineFacts>) {
let mut i = 0;
while i < nodes.len() {
let (count, b) = nodes.get(i..).map_or((0, None), |rest| block(rest, lines));
let area = b.filter(|_| count > 1).and_then(|b| area_type(nodes.get(i..i + count)?, lines, &b));
if let Some(area) = area
&& let Some(head) = nodes.get_mut(i)
{
Arc::make_mut(head).kind = NodeKind::Text(Box::new(area));
nodes.drain(i + 1..i + count);
i += 1;
} else {
// Lines that don't make area type: none of them starts it either.
i += count.max(1);
}
}
}