Files
vectorcraft/crates/pdf/src/export/text.rs
T
Ahmed Gomaa 9b797c06d1 PDF export: letters with marks are written once in the text layer
A mark glyph's vertical translation started a new draw_glyphs batch, so
krilla received the cluster's source range again and mapped its
characters twice. Copying or searching the exported PDF then found
doubled letters for Arabic harakat in any font, and for every dotted
letter in fonts that draw dots as zero-advance mark glyphs (Noto Sans,
Naskh and Kufi Arabic).

Keep compatible glyphs on the same layout line in one batch and carry
their relative vertical placement as per-glyph y_offset; horizontal
placement is unchanged (next_x - x advances). Font, size, linear
transform, line/column, paint run, list marker and hyphen boundaries
still split batches. Krilla keeps owning cluster mappings and
ActualText. No importer change.

Regressions decode the content stream and ToUnicode maps: one batch per
layout line, no duplicated cluster assignment, one ActualText per
multi-glyph cluster, contours within 0.05 pt of outline export; mixed
scripts, vertical columns, path rotations and PDF/X variants covered.
The reader round-trip stays an ignored test until the importer consumes
ActualText (#6165).

Addresses #6309.
2026-11-09 18:13:14 +02:00

457 lines
24 KiB
Rust

use vectorcraft_geom::Point;
use super::exporter::{GlyphLine, MAX_INLINE_DEPTH, in_range, norm, to_path, writable, xf};
use super::images::embed;
use super::{
Affine, AppearanceItem, BezPath, BlendMode, Color, Cow, Embedding, Exporter, Fill, FillRule, Glyph, GlyphId, KrillaGlyph, ListMarker, Mark, Node,
NormalizedF32, Paint, Path, Rect, Shape, Size, Surface, TextLayout, TextObject, krilla, write,
};
impl Exporter<'_> {
///
/// Inline graphics ([`vectorcraft_doc::TextRun::inline`]) are drawn as their symbols' art
/// (vector paths, like symbol instances) where the layout placed them; their characters
/// write no glyph.
pub(super) fn text(&mut self, s: &mut Surface, n: &Node, t: &TextObject, page: Rect) {
let doc = self.doc;
let resolved = doc.inline_resolved(t);
let t = &*resolved;
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let tb = t.xf.transform_rect_bbox(layout.bounds);
// The object's own fills and strokes paint the whole outline: those below the Characters
// row under the characters, the others over them.
let (below, above) = n.appearance.split_contents();
let all = (!n.appearance.items.is_empty()).then(|| {
let mut all = layout.to_bezpath();
for (_, bar) in vectorcraft_text::decorations(&layout, vectorcraft_text::FontDb::global(), t) {
all.extend(bar.iter());
}
all.apply_affine(t.xf);
to_path(s, &all).map(|p| (all, p))
});
let all = all.flatten();
if let Some((bp, path)) = &all {
self.text_items(s, below, bp, path, page, tb);
}
// Real text: the characters' fills as text in embedded fonts. The type's transform is
// pushed for each fill and stroke of the characters, inside its form when the fill or
// stroke overprints.
let text_space = xf(t.xf);
// Ruby can't be real text in one run (it isn't characters of the base text): the type's
// characters go out as outlines where ruby is present, ruby included.
let plain = (!self.outline_text && t.ruby.is_empty()).then(|| t.plain_text());
let bars = vectorcraft_text::decorations(&layout, vectorcraft_text::FontDb::global(), t);
for (i, run) in t.runs.iter().enumerate() {
let mut bp = BezPath::new();
for g in layout.glyphs.iter().filter(|g| g.run == i).chain(layout.ruby.iter().filter(|g| g.run == i)) {
bp.extend(g.outline.iter());
}
// Underline and strikethrough bars are paths, with real text too (#847).
let mut run_bars = BezPath::new();
for (_, bar) in bars.iter().filter(|(r, _)| *r == i) {
run_bars.extend(bar.iter());
}
bp.extend(run_bars.iter());
let fill = &run.style.fill;
// Colour bitmap glyphs (emoji) real text doesn't write: all of them, or those it leaves.
let mut images: Option<Vec<usize>> = None;
self.overprinting(s, run.style.overprint_fill, fill, |ex, s| {
s.push_transform(&text_space);
// The outlines left to fill as paths: all of them, or those real text leaves.
let outlines = match plain.as_deref().filter(|_| !fill.is_none() && !ex.area_paint(fill, true)) {
Some(text) => {
let (mut left, unwritten) = ex.glyph_text(s, &layout, i, text, fill, true, Some(false));
images = Some(unwritten);
left.extend(run_bars.iter());
Cow::Owned(left)
}
None => Cow::Borrowed(&bp),
};
let opaque = (1.0, BlendMode::Normal);
if let Some(path) = to_path(s, &outlines)
&& !ex.area(s, &path, krilla::paint::FillRule::NonZero, fill, opaque, layout.bounds, outlines.bounding_box(), true)
&& let Some(paint) = ex.paint(s, fill, layout.bounds)
{
s.set_stroke(None);
s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero }));
s.draw_path(&path);
}
s.pop();
});
// They paint themselves, whatever the fill.
let images = images.unwrap_or_else(|| layout.glyphs.iter().enumerate().filter(|(_, g)| g.run == i).map(|(gi, _)| gi).collect());
s.push_transform(&text_space);
for g in images.iter().filter_map(|&gi| layout.glyphs.get(gi)) {
self.glyph_image(s, g);
// COLR glyphs: their layers, as art.
if let Some(art) = g.placed_colour(Affine::IDENTITY) {
self.node(s, &art, page, true);
}
}
s.pop();
// Characters with neither fill nor stroke (a scanned page's recognized text), or whose
// fill is drawn as art (a dithered gradient's image, a pattern: outlines, not text),
// are still text: written invisible (rendering mode 3), so they can still be selected
// and searched.
let unpainted = fill.is_none() && !run.style.has_stroke();
if let Some(text) = plain.as_deref().filter(|_| unpainted || self.area_paint(fill, true)) {
self.invisible = true;
crate::forms::form(s, Mark::Invisible, |s| {
s.push_transform(&text_space);
// The paint only lets the writer write the text; the glyphs it can't write
// as text draw nothing, and colour bitmap glyphs are drawn already.
self.glyph_text(s, &layout, i, text, &Paint::solid(Color::gray(0.0)), false, None);
s.pop();
});
} else if let Some(text) = plain.as_deref().filter(|_| layout.glyphs.iter().any(|g| g.run == i && g.bend.is_some())) {
// Glyphs bent along a path were drawn as their outlines (text is set straight):
// they are written again as invisible text, straight where they stand, so they can
// still be selected, searched and copied.
self.invisible = true;
crate::forms::form(s, Mark::Invisible, |s| {
s.push_transform(&text_space);
self.glyph_text(s, &layout, i, text, &Paint::solid(Color::gray(0.0)), false, Some(true));
s.pop();
});
}
s.push_transform(&text_space);
let path = run.style.has_stroke().then(|| to_path(s, &bp)).flatten();
s.pop();
if let Some(path) = path {
// Character strokes are drawn in text space, with their cap, join and dashes.
let st = run.style.stroke_layer();
self.overprinting(s, st.overprint, &st.paint, |ex, s| {
s.push_transform(&text_space);
ex.stroke(s, &bp, &path, FillRule::NonZero, &st, page, layout.bounds);
s.pop();
});
}
}
s.set_fill(None);
s.set_stroke(None);
if self.inline_depth < MAX_INLINE_DEPTH {
self.inline_depth += 1;
for ig in &layout.inlines {
let Some(art) = t.runs.get(ig.run).and_then(|r| r.inline.as_ref()) else { continue };
let Some(sym) = doc.symbols.iter().find(|x| x.name == art.symbol) else { continue };
let mut node = (*sym.art).clone();
// Strokes scale with the art, as on the canvas.
node.transform(t.xf * ig.xf * doc.symbol_natural_xf(&art.symbol), true);
self.node(s, &node, page, true);
}
self.inline_depth -= 1;
}
if let Some((bp, path)) = &all {
self.text_items(s, above, bp, path, page, tb);
}
}
/// Some of a type object's own fills and strokes (`items`) on its glyph outlines `bp` (`path`;
/// `tb`: their bounds). Strokes take every stroke option, as on paths.
#[allow(clippy::too_many_arguments)]
fn text_items(&mut self, s: &mut Surface, items: &[AppearanceItem], bp: &BezPath, path: &Path, page: Rect, tb: Rect) {
for item in items {
match item {
AppearanceItem::Fill(fl) if fl.visible => self.overprinting(s, fl.overprint, &fl.paint, |ex, s| {
if !ex.area(s, path, krilla::paint::FillRule::NonZero, &fl.paint, (fl.opacity, fl.blend), tb, bp.bounding_box(), true)
&& let Some(paint) = ex.paint(s, &fl.paint, tb)
{
s.set_stroke(None);
s.set_fill(Some(Fill { paint, opacity: norm(fl.opacity), rule: krilla::paint::FillRule::NonZero }));
s.draw_path(path);
}
}),
AppearanceItem::Stroke(st) if st.visible && st.width > 0.0 => {
self.overprinting(s, st.overprint, &st.paint, |ex, s| ex.stroke(s, bp, path, FillRule::NonZero, st, page, tb))
}
_ => {}
}
}
s.set_fill(None);
s.set_stroke(None);
}
/// Fill the glyphs of run `run` of `layout` with `fill` as text in their fonts, embedded (in
/// text space; `text` is the plain text, whose characters the glyphs stand for, which makes
/// the text selectable and searchable) → the outlines of the glyphs left to draw as paths:
/// those of fonts that can't be embedded, and missing glyphs. Glyphs sharing a font, a frame
/// (size, scale, rotation) and a layout line are written together. Colour glyphs (bitmap emoji,
/// 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. 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
/// they stand straight; `None` writes all of them as text.
#[allow(clippy::too_many_arguments)]
fn glyph_text(
&mut self,
s: &mut Surface,
layout: &TextLayout,
run: usize,
text: &str,
fill: &Paint,
images: bool,
bent: Option<bool>,
) -> (BezPath, Vec<usize>) {
let (mut rest, mut unwritten) = (BezPath::new(), vec![]);
let mut line: Option<GlyphLine> = None;
// The characters the glyphs stand for: the plain text, then, when the run has glyphs that
// 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 {
continue;
}
match (bent, g.bend.is_some()) {
(Some(false), true) => {
rest.extend(g.outline.iter());
continue;
}
(Some(true), false) => continue,
_ => {}
}
// 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;
if blank && !ch.is_some_and(|c| c.is_whitespace() && !c.is_control()) {
continue;
}
// The glyph at the font size of its vertical scale: `frame` maps that size's glyph
// space (y down, krilla's) to text space.
let [a, b, c, d, ..] = g.xf.as_coeffs();
let (sx, sy) = (a.hypot(b), c.hypot(d));
let font = if g.gid != 0 && ch.is_some() { self.font(g.font_id) } else { None };
let det = g.xf.determinant();
let Some((font, upem)) = font.filter(|(_, upem)| det.is_finite() && det.abs() > 1e-9 && (1e-3..=1e5).contains(&(upem * sy))) else {
rest.extend(g.outline.iter());
if coloured {
unwritten.push(gi);
}
continue;
};
// SVG glyphs are written as text that draws nothing (see the serialize settings): their
// art is drawn, or else their outline.
if is_svg_glyph(g) {
if coloured {
unwritten.push(gi);
} else {
rest.extend(g.outline.iter());
}
}
let size = upem * sy;
let frame = g.xf * Affine::scale(1.0 / sy);
// Keep offset marks with their cluster so the writer maps its characters once.
// Layout-line identity preserves line/column boundaries despite these offsets.
let position = line
.as_ref()
.filter(|l| {
hyphen.is_none() && l.layout_line == g.line && 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;
same.then_some(Point::new(e, f))
});
let position = match position {
Some(position) => position,
None => {
if let Some(l) = line.take() {
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, layout_line: g.line, glyphs: vec![], marker: marker_at });
Point::ORIGIN
}
};
// The glyph's advance in frame units (the last one's ends the line).
let advance = g.advance * sy / sx;
if let Some(l) = line.as_mut() {
l.glyphs.push((g.gid, position, advance, range));
}
// A line-break hyphen is written on its own.
if hyphen.is_some()
&& let Some(l) = line.take()
{
self.draw_glyph_line(s, l, &written, fill, layout.bounds);
}
}
if let Some(l) = line {
let text = text_of(&l);
self.draw_glyph_line(s, l, text, fill, layout.bounds);
}
(rest, unwritten)
}
/// Draw colour bitmap glyph `g`'s image (on `s` in text space), its PNG written once.
fn glyph_image(&mut self, s: &mut Surface, g: &vectorcraft_text::PositionedGlyph) {
let Some((image, to_text)) = g.placed_image() else { return };
if !writable(s, to_text, image.rect()) {
return;
}
let cache = format!("\u{0}glyph\u{0}{}", image.blob.content_key());
let img = match self.images.get(&cache) {
Some(img) => img.clone(),
None => {
let img = embed(&image.blob, self.interpolate, self.sixteen_bit);
self.images.insert(cache, img.clone());
img
}
};
let (Some(img), Some(size)) = (img, Size::from_wh(image.width as f32, image.height as f32)) else { return };
s.push_transform(&xf(to_text));
s.draw_image(img, size);
s.pop();
}
/// Draw glyphs `line` (of `text`), filled with `fill` (whose space is text space, where
/// `bounds` is). A line that would be written with a pen position, size or spacing (in
/// thousandths of an em) beyond [`MAX_COORD`] is left out.
fn draw_glyph_line(&mut self, s: &mut Surface, line: GlyphLine, text: &str, fill: &Paint, bounds: Rect) {
let size = line.size;
let glyphs: Vec<KrillaGlyph> = line
.glyphs
.iter()
.enumerate()
.map(|(i, (gid, position, advance, range))| {
let next = line.glyphs.get(i + 1).map_or(position.x + advance, |n| n.1.x);
// Horizontal placement is already in the advances; krilla offsets use y up.
KrillaGlyph::new(GlyphId::new(*gid), ((next - position.x) / size) as f32, 0.0, (-position.y / size) as f32, 0.0, range.clone(), None)
})
.collect();
let pens = line
.glyphs
.iter()
.fold(Rect::new(0.0, -size, 0.0, size), |r, (_, p, advance, _)| r.union_pt((p.x, p.y - size)).union_pt((p.x + advance, p.y + size)));
let spacing: Vec<f64> = glyphs.iter().flat_map(|g| [f64::from(g.x_advance(1.0)) * 1000.0, f64::from(g.y_offset(1.0)) * 1000.0]).collect();
if !(writable(s, line.frame, pens) && in_range(&spacing)) {
return;
}
s.push_transform(&xf(line.frame));
if let Some(paint) = self.paint_in(s, fill, bounds, line.frame.inverse()) {
s.set_stroke(None);
s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero }));
s.draw_glyphs(krilla::geom::Point::from_xy(0.0, 0.0), &glyphs, line.font, text, size as f32, false);
}
s.pop();
}
/// The font of face `id` for real text and its units per em; `None` (with a warning) when its
/// licence doesn't allow embedding it as a subset, or it can't be read.
fn font(&mut self, id: u32) -> Option<(krilla::text::Font, f64)> {
if let Some(f) = self.fonts.get(&id) {
return f.clone();
}
let Some(face) = vectorcraft_text::FontDb::global().face_by_id(id) else {
self.fonts.insert(id, None);
return None;
};
let name = format!("{} {}", face.family, face.style);
let font = match face.embedding() {
Embedding::Subset => {
// A named instance of a variable font is embedded instanced (its own outlines and
// widths, not the default instance's).
match write::font(face.file_data(), face.face_index(), Some(face.variations())) {
Ok(font) => Some((font, face.units_per_em())),
Err(error) => {
self.warn(format!("the font “{name}” can't be embedded ({error}): its text is exported as outlines"));
None
}
}
}
Embedding::Whole => {
self.warn(format!(
"the font “{name}” may only be embedded whole, and fonts are embedded as subsets: its text is exported as outlines"
));
None
}
Embedding::Forbidden => {
self.warn(format!("the licence of the font “{name}” doesn't allow embedding: its text is exported as outlines"));
None
}
};
self.fonts.insert(id, font.clone());
font
}
}
/// Is `g` drawn from its font's SVG document, which the PDF writer can't draw as a font's glyph?
fn is_svg_glyph(g: &vectorcraft_text::PositionedGlyph) -> bool {
vectorcraft_text::FontDb::global().is_svg_glyph(g.font_id, g.gid)
}
/// The characters a line-break hyphen can stand for, as real text: '-' and U+2010 (HYPHEN).
const BREAK_HYPHENS: &str = "-\u{2010}";
/// The bytes of [`BREAK_HYPHENS`] that glyph `g` stands for when it is a hyphen the layout adds
/// where it breaks a line inside a word: a glyph that stands for no character of the text (length
/// 0) and that its face's character map gives to '-' or, failing that, to U+2010. `None` for any
/// other glyph, kashida tatweels included.
fn break_hyphen(g: &vectorcraft_text::PositionedGlyph) -> Option<std::ops::Range<usize>> {
if g.len != 0 || g.gid == 0 {
return None;
}
let face = vectorcraft_text::FontDb::global().face_by_id(g.font_id)?;
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
}