Files
designcraft/crates/pdf/src/text.rs
T
2026-10-04 00:46:52 -04:00

366 lines
15 KiB
Rust

//! Composed text → PDF text objects with embedded (subsetted) fonts.
//!
//! Each line is split into runs of glyphs sharing face, style, scale and baseline offset. A run is
//! drawn with one `draw_glyphs` call: the run origin, horizontal scale and skew go into the
//! transform, glyph advances are taken from the composed x positions (so justification, tracking
//! and kerning are exact), and every glyph carries the story text it came from so viewers can
//! select, search and copy it.
use std::collections::HashMap;
use std::ops::Range;
use std::sync::Arc;
use designcraft_compose::{ComposedStory, FrameText, PlacedGlyph};
use designcraft_fonts::{FontDb, FontFace};
use designcraft_geom::kurbo::Shape as _;
use designcraft_geom::{Affine, BezPath, Rect};
use krilla::geom::Point;
use krilla::paint::Stroke;
use krilla::surface::Surface;
use krilla::tagging::{Artifact, ArtifactType, ContentTag, SpanTag};
use krilla::text::{Font, GlyphId, KrillaGlyph};
use crate::export::{Exporter, solid_fill, tf, to_path};
/// Private-use characters DesignCraft stores for markers (page numbers, section markers, breaks).
fn is_marker(c: char) -> bool {
('\u{E000}'..='\u{E1FF}').contains(&c)
}
fn same_run(a: &PlacedGlyph, b: &PlacedGlyph) -> bool {
b.visible
&& a.face.id() == b.face.id()
&& a.style == b.style
&& (a.sx - b.sx).abs() < 1e-9
&& (a.sy - b.sy).abs() < 1e-9
&& (a.y - b.y).abs() < 1e-6
}
impl Exporter<'_> {
pub(crate) fn font(&mut self, face: &FontFace) -> Option<Font> {
self.fonts
.entry(face.id())
.or_insert_with(|| {
if face.is_variable() {
// A named instance: krilla embeds the font at these axis settings.
let coords: Vec<(krilla::text::Tag, f32)> = face.coords.iter().map(|(t, v)| (krilla::text::Tag::new(t), *v)).collect();
Font::new_variable(face.data().to_vec().into(), face.index(), &coords)
} else {
Font::new(face.data().to_vec().into(), face.index())
}
})
.clone()
}
fn reverse_cmap(&mut self, face: &FontFace) -> Arc<HashMap<u32, char>> {
self.reverse_cmaps
.entry(face.id())
.or_insert_with(|| {
let mut m = HashMap::new();
// Sorted by code point: the lowest code point wins (`-` before U+2010).
for (c, g) in face.chars() {
m.entry(g).or_insert(c);
}
Arc::new(m)
})
.clone()
}
/// Draw `f` as an artifact while a story is being tagged (nested text isn't tagged).
fn as_artifact(&mut self, s: &mut Surface, f: impl FnOnce(&mut Self, &mut Surface)) {
match self.tag_story.take() {
Some(sid) => {
s.start_tagged(ContentTag::Artifact(Artifact::new(ArtifactType::Other, None)));
f(self, s);
s.end_tagged();
self.tag_story = Some(sid);
}
None => f(self, s),
}
}
/// A glyph run, tagged under its paragraph while a story is being tagged.
fn tagged_run(&mut self, s: &mut Surface, cs: &ComposedStory, gs: &[PlacedGlyph], baseline: f64, story: &str) {
match self.tag_story {
Some(sid) => {
let pi = self.doc.story(sid).map_or(0, |st| st.para_at(gs[0].byte.min(st.len())));
let id = s.start_tagged(ContentTag::Span(SpanTag::empty()));
self.run(s, cs, gs, baseline, story);
s.end_tagged();
self.para_tags.entry((sid, pi)).or_default().push(id);
}
None => self.run(s, cs, gs, baseline, story),
}
}
pub(crate) fn frame_text(&mut self, s: &mut Surface, cs: &ComposedStory, ft: &FrameText, story: &str) {
// Paragraph shading and rules under the text.
self.as_artifact(s, |me, s| {
for d in &ft.decos {
if let Some(c) = me.swatch_color(&d.color, d.tint) {
me.fill_rect(s, d.rect, c);
}
}
if !ft.tables.is_empty() {
me.tables(s, ft);
}
});
let mut deco: Vec<(designcraft_compose::Rule, Rect)> = Vec::new();
for l in &ft.lines {
let gs = &l.glyphs;
let mut i = 0;
while i < gs.len() {
let g = &gs[i];
if !g.visible || g.sx <= 0.0 || g.sy <= 0.0 {
i += 1;
continue;
}
// Vertical type: an upright glyph turns back a quarter about its em box centre.
if ft.vertical
&& let Some(turn) = g.vertical_xf(l.baseline)
{
s.push_transform(&crate::export::tf(turn));
self.tagged_run(s, cs, &gs[i..i + 1], l.baseline, story);
s.pop();
i += 1;
continue;
}
let mut j = i + 1;
while j < gs.len() && same_run(g, &gs[j]) && !(ft.vertical && (gs[j].upright || gs[j].tcy.is_some())) {
j += 1;
}
self.tagged_run(s, cs, &gs[i..j], l.baseline, story);
i = j;
}
// Underline / strikethrough, merged per style along the line.
for g in gs.iter().filter(|g| g.visible) {
let st = &cs.styles[g.style as usize];
for (rule, r) in st.rules(g.x, g.x + g.adv, l.baseline) {
match deco.last_mut() {
Some((last_rule, last)) if *last_rule == *rule && (last.y0 - r.y0).abs() < 1e-6 && (r.x0 - last.x1).abs() < 0.5 => {
last.x1 = r.x1
}
_ => deco.push((rule.clone(), r)),
}
}
}
}
self.as_artifact(s, |me, s| {
for (rule, r) in deco {
if let Some(c) = me.swatch_color(&rule.color, rule.tint) {
me.fill_rect(s, r, c);
}
}
for n in &ft.notes {
if let Some(nft) = n.text.frames.first() {
s.push_transform(&tf(Affine::translate(n.origin.to_vec2())));
me.frame_text(s, &n.text, nft, &n.source);
s.pop();
}
}
});
}
/// Table fragments: cell fills, cell text (real text), edges and the border.
fn tables(&mut self, s: &mut Surface, ft: &FrameText) {
for t in &ft.tables {
for c in &t.cells {
if let Some((sw, tint)) = &c.fill
&& let Some(col) = self.swatch_color(sw, *tint)
{
self.fill_rect(s, c.rect, col);
}
}
for c in &t.cells {
if let Some(g) = &c.graphic {
if let Some(clip) = to_path(&c.clip.to_path(0.1)) {
s.push_clip_path(&clip, &krilla::paint::FillRule::NonZero);
s.push_transform(&tf(Affine::translate((c.clip.x0, c.clip.y0))));
self.graphic(s, g);
s.pop();
s.pop();
}
} else if let Some(cft) = c.text.frames.first() {
s.push_transform(&tf(Affine::translate(c.origin.to_vec2())));
self.frame_text(s, &c.text, cft, &c.source);
s.pop();
}
}
for seg in &t.strokes {
let st = &seg.stroke;
let Some(col) = self.swatch_color(&st.color, st.tint) else { continue };
let (cap, dash) = match &st.kind {
designcraft_doc::StrokeType::Dashed { pattern } if pattern.iter().any(|v| *v > 0.0) => {
let mut pat: Vec<f32> = pattern.iter().map(|v| v.max(0.0) as f32).collect();
if pat.len() % 2 == 1 {
pat.extend(pat.clone());
}
(krilla::paint::LineCap::Butt, Some(krilla::paint::StrokeDash { array: pat, offset: 0.0 }))
}
designcraft_doc::StrokeType::Dotted => {
(krilla::paint::LineCap::Round, Some(krilla::paint::StrokeDash { array: vec![0.0, (st.weight * 2.0) as f32], offset: 0.0 }))
}
_ => (krilla::paint::LineCap::Square, None),
};
let mut bp = BezPath::new();
bp.move_to(seg.a);
bp.line_to(seg.b);
let Some(p) = to_path(&bp) else { continue };
s.set_fill(None);
s.set_stroke(Some(Stroke { paint: col.into(), width: st.weight as f32, line_cap: cap, dash, ..Default::default() }));
s.draw_path(&p);
s.set_stroke(None);
}
}
}
fn fill_rect(&self, s: &mut Surface, r: Rect, c: krilla::color::Color) {
if let Some(p) = to_path(&r.to_path(0.1)) {
s.set_stroke(None);
s.set_fill(Some(solid_fill(c, 1.0)));
s.draw_path(&p);
s.set_fill(None);
}
}
/// Unicode text for a run's glyphs: (text, per-glyph byte range into it).
fn run_text(&mut self, glyphs: &[PlacedGlyph], story: &str) -> (String, Vec<Range<usize>>) {
let cmap = self.reverse_cmap(&glyphs[0].face);
let mut text = String::new();
let mut ranges: Vec<Range<usize>> = Vec::with_capacity(glyphs.len());
// Byte of the previous glyph when it came straight from the story (cluster continuation).
let mut prev_src: Option<usize> = None;
for g in glyphs {
let src = story.get(g.byte..g.byte + g.len).filter(|t| !t.is_empty() && !t.chars().any(is_marker));
let range = if let Some(t) = src {
prev_src = Some(g.byte);
let a = text.len();
text.push_str(t);
a..text.len()
} else if g.len == 0 && prev_src == Some(g.byte) && !ranges.is_empty() {
// Second glyph of a multi-glyph cluster: share the cluster's text.
ranges[ranges.len() - 1].clone()
} else {
// Inserted glyphs (page numbers, list labels, hyphens): map back through the font.
prev_src = None;
match cmap.get(&g.gid) {
Some(c) => {
let a = text.len();
text.push(*c);
a..text.len()
}
None => match ranges.last() {
Some(r) => r.clone(),
None => {
text.push('\u{FFFD}');
0..text.len()
}
},
}
};
ranges.push(range);
}
(text, ranges)
}
fn run(&mut self, s: &mut Surface, cs: &ComposedStory, glyphs: &[PlacedGlyph], baseline: f64, story: &str) {
let g0 = &glyphs[0];
let st = &cs.styles[g0.style as usize];
let fill = self.swatch_color(&st.fill, st.fill_tint);
let stroke =
if st.stroke != designcraft_color::swatch::NONE && st.stroke_weight > 0.0 { self.swatch_color(&st.stroke, st.stroke_tint) } else { None };
if fill.is_none() && stroke.is_none() {
return;
}
let face = g0.face;
let size = g0.sy * face.upem;
let hs = g0.sx / g0.sy;
let skew = if st.skew != 0.0 { Affine::new([1.0, 0.0, -st.skew.to_radians().tan(), 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
let origin = Affine::translate((g0.x, baseline + g0.y)) * skew * Affine::scale_non_uniform(hs, 1.0);
let Some(font) = self.font(&face) else {
self.warn(format!("font `{} {}` could not be embedded; its text was drawn as outlines", face.family, face.style));
self.outline_run(s, glyphs, origin, size, fill, stroke, st.stroke_weight);
return;
};
let (text, ranges) = self.run_text(glyphs, story);
let k = 1.0 / (hs * size);
let kg: Vec<KrillaGlyph> = glyphs
.iter()
.zip(ranges)
.enumerate()
.map(|(i, (g, r))| {
let next = glyphs.get(i + 1).map_or(g.x + g.adv, |n| n.x);
KrillaGlyph::new(GlyphId::new(g.gid), ((next - g.x) * k) as f32, 0.0, 0.0, 0.0, r, None)
})
.collect();
s.push_transform(&tf(origin));
s.set_fill(fill.map(|c| solid_fill(c, 1.0)));
s.set_stroke(stroke.map(|c| Stroke { paint: c.into(), width: st.stroke_weight as f32, ..Default::default() }));
s.draw_glyphs(Point::from_xy(0.0, 0.0), &kg, font, &text, size as f32, false);
s.set_fill(None);
s.set_stroke(None);
s.pop();
}
/// Fallback for faces krilla can't embed: glyph outlines as paths.
#[allow(clippy::too_many_arguments)]
fn outline_run(
&mut self,
s: &mut Surface,
glyphs: &[PlacedGlyph],
origin: Affine,
size: f64,
fill: Option<krilla::color::Color>,
stroke: Option<krilla::color::Color>,
stroke_weight: f64,
) {
let db = FontDb::global();
let g0 = &glyphs[0];
let k = size / g0.face.upem;
let mut bp = BezPath::new();
for g in glyphs {
let a = Affine::translate(((g.x - g0.x) / (g0.sx / g0.sy), 0.0)) * Affine::scale(k);
let mut o = (*db.outline(&g.face, g.gid)).clone();
o.apply_affine(a);
bp.extend(o.iter());
}
let Some(p) = to_path(&bp) else { return };
s.push_transform(&tf(origin));
s.set_fill(fill.map(|c| solid_fill(c, 1.0)));
s.set_stroke(stroke.map(|c| Stroke { paint: c.into(), width: stroke_weight as f32, ..Default::default() }));
s.draw_path(&p);
s.set_fill(None);
s.set_stroke(None);
s.pop();
}
/// One line of plain text in the bundled UI face (page information mark).
pub(crate) fn plain_text(&mut self, s: &mut Surface, text: &str, at: (f64, f64), size: f64, color: krilla::color::Color) {
let face = FontDb::global().face(designcraft_fonts::FALLBACK_FAMILY, "Regular");
let Some(font) = self.font(&face) else { return };
let shaped = designcraft_fonts::shape(&face, text, &[], |c| c);
let upem = face.upem as f32;
let glyphs: Vec<KrillaGlyph> = shaped
.iter()
.enumerate()
.map(|(i, g)| {
let end = shaped.iter().skip(i + 1).map(|n| n.cluster).find(|c| *c != g.cluster).unwrap_or(text.len());
let end = if end > g.cluster { end } else { text.len() };
KrillaGlyph::new(
GlyphId::new(g.gid),
g.x_advance as f32 / upem,
g.x_offset as f32 / upem,
g.y_offset as f32 / upem,
0.0,
g.cluster..end,
None,
)
})
.collect();
s.set_stroke(None);
s.set_fill(Some(solid_fill(color, 1.0)));
s.draw_glyphs(Point::from_xy(at.0 as f32, at.1 as f32), &glyphs, font, text, size as f32, false);
s.set_fill(None);
}
}