PDF: list markers export as selectable text

Squashed from #4098 onto main.
This commit is contained in:
dotCooCoo
2026-10-26 21:00:30 +07:00
committed by bflatastic
parent a05b1dd75a
commit 7705983a72
4 changed files with 279 additions and 22 deletions
+76 -16
View File
@@ -21,8 +21,8 @@ use vectorcraft_color::{BlendMode, Color, GradientKind, Paint};
use vectorcraft_doc::{AppearanceItem, Document, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, SymbolBoxes, TextObject};
use vectorcraft_effects::stroke::{self, WrittenShape};
use vectorcraft_geom::{Affine, BezPath, FillRule, Rect};
use vectorcraft_text::TextLayout;
use vectorcraft_text::embed::Embedding;
use vectorcraft_text::{ListMarker, TextLayout};
use crate::forms::Mark;
use crate::lab_spot::{find, rfind};
@@ -694,8 +694,11 @@ struct GlyphLine {
/// The glyph space (krilla's, y down, at `size`) → text space, at the line's start.
frame: Affine,
/// Glyph id, pen position and advance along the line (in glyph space), and the characters it
/// stands for (bytes of the plain text).
/// stands for (bytes of the plain text, or of the list marker's text).
glyphs: Vec<(u32, f64, f64, std::ops::Range<usize>)>,
/// The list marker the glyphs belong to, by its first glyph's index in the layout
/// ([`TextLayout::marker_of`]); `None` for glyphs of the plain text.
marker: Option<usize>,
}
/// Most pixels along a side of a freeform gradient's image (its colour field is smooth).
@@ -1780,7 +1783,9 @@ impl Exporter<'_> {
/// COLR, SVG), with `images`, are written as text too, which draws their images or layers; the
/// indices of those it can't write or draw (SVG glyphs) are returned with the outlines. The
/// hyphen the layout adds where it breaks a line inside a word ([`break_hyphen`]) is written
/// as the character its glyph stands for, on its own after the glyphs before it.
/// as the character its glyph stands for, on its own after the glyphs before it. The glyphs of
/// a list marker are written as the marker's characters ([`marker_chars`]), apart from the
/// plain text's glyphs; a marker glyph that stands for none of them is drawn as a path.
///
/// `bent`: `Some(false)` writes the glyphs bent along a path ([`vectorcraft_text::GlyphBend`])
/// as outlines, not text (text is set straight); `Some(true)` writes only those, as text where
@@ -1802,6 +1807,11 @@ impl Exporter<'_> {
// stand for no character of it, the characters a line-break hyphen can stand for.
let written: Cow<str> =
if layout.glyphs.iter().any(|g| g.run == run && g.len == 0) { Cow::Owned(format!("{text}{BREAK_HYPHENS}")) } else { Cow::Borrowed(text) };
let written_text: &str = &written;
// The text a line's glyphs stand for: `written`, or its list marker's characters.
let text_of = |l: &GlyphLine| l.marker.and_then(|m| layout.marker_of(m)).map_or(written_text, |m| m.text.as_str());
// The characters of the list marker being written that each of its glyphs stands for.
let mut marker: Option<(usize, Vec<Option<std::ops::Range<usize>>>)> = None;
for (gi, g) in layout.glyphs.iter().enumerate().filter(|(_, g)| g.run == run) {
let coloured = g.image.is_some() || g.colour.is_some();
if coloured && !images {
@@ -1815,11 +1825,27 @@ impl Exporter<'_> {
(Some(true), false) => continue,
_ => {}
}
// A list marker's glyphs stand for no character of the text either; they are not
// line-break hyphens, whatever the marker.
let hyphen = if layout.marker_of(gi).is_some() { None } else { break_hyphen(g) };
let range = hyphen.clone().map_or(g.byte..g.byte + g.len, |h| text.len() + h.start..text.len() + h.end);
let ch = written.get(range.clone()).and_then(|t| t.chars().next());
// Of the glyphs with no length, only a list marker's and line-break hyphens are written
// as text; the others, such as kashidas, are drawn as paths. A list marker's glyphs are
// not line-break hyphens, whatever the marker.
let in_marker = (g.len == 0).then(|| layout.marker_of(gi)).flatten();
let hyphen = if in_marker.is_some() { None } else { break_hyphen(g) };
let (range, ch) = match in_marker {
Some(m) => {
if marker.as_ref().is_none_or(|(start, _)| *start != m.glyphs.start) {
marker = Some((m.glyphs.start, marker_chars(layout, m)));
}
let range = marker.as_ref().and_then(|(start, chars)| chars.get(gi.checked_sub(*start)?).cloned().flatten());
let ch = range.clone().and_then(|r| m.text.get(r)).and_then(|t| t.chars().next());
(range.unwrap_or(0..0), ch)
}
None => {
let range = hyphen.clone().map_or(g.byte..g.byte + g.len, |h| text.len() + h.start..text.len() + h.end);
let ch = written.get(range.clone()).and_then(|t| t.chars().next());
(range, ch)
}
};
let marker_at = in_marker.map(|m| m.glyphs.start);
// Spaces are written too (text extraction needs them); control characters and soft
// hyphens draw nothing.
let blank = g.outline.elements().is_empty() && !coloured;
@@ -1852,18 +1878,22 @@ impl Exporter<'_> {
let frame = g.xf * Affine::scale(1.0 / sy);
// Its pen position along the frame's baseline, when it continues the line (a
// line-break hyphen never does).
let x = line.as_ref().filter(|l| hyphen.is_none() && l.face == g.font_id && (l.size - size).abs() < 1e-6).and_then(|l| {
let [a, b, c, d, e, f] = (l.frame.inverse() * frame).as_coeffs();
let same = (a - 1.0).abs() < 1e-6 && b.abs() < 1e-6 && c.abs() < 1e-6 && (d - 1.0).abs() < 1e-6 && f.abs() < 1e-4;
same.then_some(e)
});
let x = line
.as_ref()
.filter(|l| hyphen.is_none() && l.face == g.font_id && (l.size - size).abs() < 1e-6 && l.marker == marker_at)
.and_then(|l| {
let [a, b, c, d, e, f] = (l.frame.inverse() * frame).as_coeffs();
let same = (a - 1.0).abs() < 1e-6 && b.abs() < 1e-6 && c.abs() < 1e-6 && (d - 1.0).abs() < 1e-6 && f.abs() < 1e-4;
same.then_some(e)
});
let x = match x {
Some(x) => x,
None => {
if let Some(l) = line.take() {
self.draw_glyph_line(s, l, &written, fill, layout.bounds);
let text = text_of(&l);
self.draw_glyph_line(s, l, text, fill, layout.bounds);
}
line = Some(GlyphLine { face: g.font_id, font, size, frame, glyphs: vec![] });
line = Some(GlyphLine { face: g.font_id, font, size, frame, glyphs: vec![], marker: marker_at });
0.0
}
};
@@ -1880,7 +1910,8 @@ impl Exporter<'_> {
}
}
if let Some(l) = line {
self.draw_glyph_line(s, l, &written, fill, layout.bounds);
let text = text_of(&l);
self.draw_glyph_line(s, l, text, fill, layout.bounds);
}
(rest, unwritten)
}
@@ -2129,6 +2160,35 @@ fn break_hyphen(g: &vectorcraft_text::PositionedGlyph) -> Option<std::ops::Range
BREAK_HYPHENS.char_indices().find(|&(_, c)| face.glyph_for(c) == g.gid).map(|(i, c)| i..i + c.len_utf8())
}
/// The characters of list marker `m` of `layout` that each of its glyphs stands for, as bytes of
/// the marker's text, in the order of [`ListMarker::glyphs`]: `None` for its tab and for a glyph
/// that stands for none of them. A glyph stands for the first character left that the glyph's
/// font maps to that glyph in its character map. When the glyphs that no character maps to (an
/// OpenType feature such as oldstyle figures replaced them) are as many as the characters left,
/// they stand for those characters, in order.
fn marker_chars(layout: &TextLayout, m: &ListMarker) -> Vec<Option<std::ops::Range<usize>>> {
let db = vectorcraft_text::FontDb::global();
let mut left: Vec<(std::ops::Range<usize>, char)> = m.text.char_indices().map(|(i, c)| (i..i + c.len_utf8(), c)).collect();
let mut chars = vec![None; m.glyphs.len()];
let mut unmapped = vec![];
for (k, gi) in m.glyphs.clone().enumerate() {
let Some(g) = layout.glyphs.get(gi).filter(|g| gi != m.tab && g.gid != 0) else { continue };
let face = db.face_by_id(g.font_id);
match (left.iter().position(|(_, c)| face.as_ref().is_some_and(|f| f.glyph_for(*c) == g.gid)), chars.get_mut(k)) {
(Some(at), Some(slot)) => *slot = Some(left.remove(at).0),
_ => unmapped.push(k),
}
}
if unmapped.len() == left.len() {
for (k, (range, _)) in unmapped.into_iter().zip(left) {
if let Some(slot) = chars.get_mut(k) {
*slot = Some(range);
}
}
}
chars
}
#[cfg(test)]
mod tests {
use super::*;
+193 -2
View File
@@ -5,8 +5,9 @@ use std::sync::Arc;
use serde_json::json;
use vectorcraft_color::{Color, Gradient, GradientPaint, Paint};
use vectorcraft_doc::{CharStyle, Document, Effect, Node, NodeKind, TextKind, TextObject, TextRun};
use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect};
use vectorcraft_doc::text::{Bullet, NumberStyle, ParaList};
use vectorcraft_doc::{CharStyle, Document, Effect, Node, NodeKind, ParaStyle, TextKind, TextObject, TextRun};
use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect, shapes};
use vectorcraft_text::{FALLBACK_FAMILY, FontDb};
use crate::*;
@@ -303,6 +304,196 @@ fn real_text_lands_within_half_a_point_of_the_outlines() {
}
}
/// Area type 200 points wide holding `text`, its paragraphs in lists `lists`, with an 18 pt
/// hanging indent.
fn listed(text: &str, lists: &[Option<ParaList>]) -> TextObject {
let mut t = TextObject::point(Point::ZERO, text, style(12.0));
t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(20.0, 20.0, 220.0, 280.0)) };
t.xf = Affine::IDENTITY;
t.para.left_indent = 18.0;
t.para.first_line_indent = -18.0;
let styles = lists.iter().map(|&list| ParaStyle { list, ..t.para.clone() }).collect();
t.set_paragraph_styles(styles);
t
}
/// The origins (document space) of the glyphs of each list marker of the type objects of `d`, in
/// the order they are drawn, the marker's tab left out.
fn marker_origins(d: &Document) -> Vec<Vec<Point>> {
let mut out = vec![];
d.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
let lay = vectorcraft_text::layout(FontDb::global(), t);
for m in &lay.markers {
out.push(m.glyphs.clone().filter(|&i| i != m.tab).map(|i| t.xf * lay.glyphs[i].origin).collect());
}
}
});
out
}
/// Each glyph of the type objects of `d` that stands for characters of its text: the first of
/// them and the glyph's origin in document space.
fn glyph_chars(d: &Document) -> Vec<(char, Point)> {
let mut out = vec![];
d.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
let text = t.plain_text();
for g in vectorcraft_text::layout(FontDb::global(), t).glyphs.iter().filter(|g| g.len > 0) {
if let Some(c) = text.get(g.byte..).and_then(|s| s.chars().next()) {
out.push((c, t.xf * g.origin));
}
}
}
});
out
}
/// Does `text` hold `a` before `b`?
fn before(text: &str, a: &str, b: &str) -> bool {
matches!((text.find(a), text.find(b)), (Some(x), Some(y)) if x < y)
}
/// `back`, the export of `d` reopened, has glyphs of the characters `drawn` (each list marker's,
/// in the order the canvas draws them) within `near` points of where `d` draws its markers'
/// glyphs.
fn assert_markers_in_place(d: &Document, back: &Document, drawn: &[&str], near: f64) {
let want = marker_origins(d);
assert_eq!(want.len(), drawn.len(), "{drawn:?}");
let got = glyph_chars(back);
for (origins, chars) in want.iter().zip(drawn) {
assert_eq!(origins.len(), chars.chars().count(), "{chars}");
for (p, c) in origins.iter().zip(chars.chars()) {
assert!(got.iter().any(|(gc, gp)| *gc == c && gp.distance(*p) < near), "{c:?} at {p:?}: {got:?}");
}
}
}
/// List markers (Paragraph panel › Bullets and Numbered List) are written as text: reopened, the
/// export holds each bullet and number as characters, before its paragraph's text and where the
/// canvas draws them, and the page looks as it does with outlined type.
#[test]
fn list_markers_are_written_as_text_where_the_canvas_draws_them() {
let number = Some(ParaList::numbered(NumberStyle::Arabic));
for (bullet, character) in [(Bullet::Dot, "\u{2022}"), (Bullet::Dash, "\u{2013}"), (Bullet::Circle, "\u{25E6}"), (Bullet::Square, "\u{25AA}")] {
let bullet = Some(ParaList::bulleted(bullet));
let t = listed("alpha beta gamma delta epsilon zeta eta theta\nbullet\nfirst\nsecond", &[bullet, bullet, number, number]);
let d = doc(vec![t]);
let back = import_as(&pdf(&d, false).bytes, TextAs::Text);
let all = texts(&back).concat();
assert_eq!(all.matches(character).count(), 2, "{all}");
assert!(before(&all, character, "alpha") && before(&all, "1.", "first") && before(&all, "2.", "second"), "{all}");
assert_markers_in_place(&d, &back, &[character, character, "1.", "2."], 0.1);
let (real, outlined) = (ink(&import_as(&pdf(&d, false).bytes, TextAs::Outlines)), ink(&import_as(&pdf(&d, true).bytes, TextAs::Outlines)));
let off =
[real.x0 - outlined.x0, real.y0 - outlined.y0, real.x1 - outlined.x1, real.y1 - outlined.y1].iter().fold(0.0f64, |m, v| m.max(v.abs()));
assert!(off < 0.5, "{character}: {real:?} vs {outlined:?}");
}
}
/// A right-to-left numbered paragraph's number is written as its characters where the canvas
/// draws them (the period, then 1 and 2, from the left), and the paragraph's own text comes back
/// as it does without a list.
#[test]
fn a_right_to_left_number_is_written_as_text_where_the_canvas_draws_it() {
let hebrew = "שלום עולם";
let mut plain = listed(hebrew, &[None]);
plain.edit_paras(None, |p| p.justify = vectorcraft_doc::Justify::Auto);
let mut numbered = plain.clone();
numbered.edit_paras(None, |p| p.list = Some(ParaList::numbered(NumberStyle::Arabic)));
numbered.list_count = 11;
let d = doc(vec![numbered]);
let back = import_as(&pdf(&d, false).bytes, TextAs::Text);
// The reader tracks a reopened right-to-left line as a whole, and the gap before the number
// comes back as a space of the Hebrew font's width, so the number may sit up to a tenth of an
// em (1.2 pt) away; its characters keep their spacing to within 0.1 pt.
assert_markers_in_place(&d, &back, &[".12"], 1.2);
let drawn = marker_origins(&d).concat();
let got = glyph_chars(&back);
let at = |c: char| got.iter().find(|(gc, _)| *gc == c).map(|(_, p)| p.x);
for (k, (a, b)) in [('.', '1'), ('1', '2')].into_iter().enumerate() {
let (Some(pa), Some(pb), Some(da), Some(db)) = (at(a), at(b), drawn.get(k), drawn.get(k + 1)) else {
panic!("{a:?} and {b:?} in {got:?}");
};
assert!(((pb - pa) - (db.x - da.x)).abs() < 0.1, "{a:?} to {b:?}: {} against {}", pb - pa, db.x - da.x);
}
let without = texts(&import_as(&pdf(&doc(vec![plain]), false).bytes, TextAs::Text)).concat();
let all = texts(&back).concat();
assert!(all.contains(without.trim()), "{all:?} holds {without:?}");
}
/// Type on a path writes its bullet as text where the canvas draws it along the path.
#[test]
fn a_bullet_on_a_path_is_written_as_text_where_the_canvas_draws_it() {
let mut t = TextObject::point(Point::ZERO, "along the arc", style(18.0));
t.kind = TextKind::OnPath { path: arc(), start: 0.0, end: None };
t.xf = Affine::IDENTITY;
t.para.list = Some(ParaList::bulleted(Bullet::Dot));
let d = doc(vec![t]);
let back = import_as(&pdf(&d, false).bytes, TextAs::Text);
assert!(texts(&back).concat().contains('\u{2022}'), "{:?}", texts(&back));
assert_markers_in_place(&d, &back, &["\u{2022}"], 0.3);
}
/// Threaded frames write each marker their layouts draw as text, the number of the paragraph the
/// first frame splits included, in each frame where the canvas draws it.
#[test]
fn numbers_in_threaded_frames_are_written_as_text_in_each_frame() {
let numbered =
ParaStyle { list: Some(ParaList::numbered(NumberStyle::Arabic)), left_indent: 18.0, first_line_indent: -18.0, ..Default::default() };
let frame = |r: Rect| {
let mut t = TextObject::point(Point::ZERO, "", style(12.0));
t.kind = TextKind::Area { frame: shapes::rectangle(r) };
t.xf = Affine::IDENTITY;
t
};
let (mut a, mut b) = (frame(Rect::new(20.0, 20.0, 180.0, 80.0)), frame(Rect::new(200.0, 20.0, 360.0, 280.0)));
let text = format!("One\nTwo\n{}\nFour", "The quick brown fox jumps over the lazy dog. ".repeat(4));
let story = vec![TextRun { text, style: style(12.0), inline: None }];
let parts = vectorcraft_text::thread::distribute(FontDb::global(), &[&a, &b], &story, &vec![numbered; 4]);
for (f, part) in [&mut a, &mut b].into_iter().zip(&parts) {
f.runs = part.runs.clone();
f.set_paragraph_styles(part.paras.clone());
f.list_count = part.list_count;
}
let first = a.plain_text();
assert!(first.contains("quick") && !first.ends_with('\n'), "the first frame splits the third paragraph: {first:?}");
let d = doc(vec![a, b]);
let mut drawn: Vec<String> = vec![];
d.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
drawn.extend(vectorcraft_text::layout(FontDb::global(), t).markers.into_iter().map(|m| m.text));
}
});
assert!(drawn.contains(&"4.".to_string()), "{drawn:?}");
let back = import_as(&pdf(&d, false).bytes, TextAs::Text);
assert_markers_in_place(&d, &back, &drawn.iter().map(String::as_str).collect::<Vec<_>>(), 0.1);
}
/// A number set in oldstyle figures (the first character's OpenType features) is written as its
/// digits, though the character map gives the digits other glyphs. The reopened type has no
/// OpenType features and sets lining figures, so only where each number starts is compared.
#[test]
fn a_number_in_oldstyle_figures_is_written_as_its_digits() {
let number = Some(ParaList::numbered(NumberStyle::Arabic));
let mut t = listed("first\nsecond", &[number, number]);
for r in &mut t.runs {
r.style.features = vec!["onum".into()];
}
let lay = vectorcraft_text::layout(FontDb::global(), &t);
let one = &lay.glyphs[lay.markers[0].glyphs.start];
let face = FontDb::global().face_by_id(one.font_id).unwrap();
assert_ne!(face.glyph_for('1'), one.gid, "an oldstyle 1, not the character map's");
let d = doc(vec![t]);
let back = import_as(&pdf(&d, false).bytes, TextAs::Text);
let all = texts(&back).concat();
assert!(before(&all, "1.", "first") && before(&all, "2.", "second"), "{all:?}");
let got = glyph_chars(&back);
for (origins, digit) in marker_origins(&d).iter().zip(['1', '2']) {
assert!(got.iter().any(|(c, p)| *c == digit && p.distance(origins[0]) < 0.1), "{digit} at {:?}: {got:?}", origins[0]);
}
}
/// A run shifted two billion points off its baseline is left out, as real text or outlines, and
/// the rest of the type is written. On a page 50 points high, the page's y flip puts the shifted
/// run's baseline at -2,147,483,648, which the PDF writer panics on in builds with overflow checks.