mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-26 11:16:50 -06:00
PDF: glyphs in a right-to-left line's span open no ActualText span of their own
krilla wraps a mark cluster that maps to several glyphs, and a glyph drawn with other text than its font already maps it to, in an ActualText span. Inside a line's ActualText span that nested the spans, and poppler drops the text before a nested span. Inside a line span each glyph now gets the text its font maps it to (the exporter keeps a copy of krilla's mappings), a cluster's text goes to its first glyph, and a run splits into separate draw calls where two glyphs in a row would share an empty range. A run drawn through tagged_run inside a line span takes the span as its tag. Test: mark_clusters_in_right_to_left_lines_open_no_span_of_their_own (5 nested spans before).
This commit is contained in:
@@ -162,6 +162,8 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions)
|
||||
story_tags: HashMap::new(),
|
||||
tag_story: None,
|
||||
para_tags: HashMap::new(),
|
||||
in_line_span: false,
|
||||
to_unicode: HashMap::new(),
|
||||
};
|
||||
if ex.rgb_only {
|
||||
ex.warn("PDF/A: CMYK colours were converted to RGB (no CMYK output intent profile is available yet)");
|
||||
@@ -339,6 +341,8 @@ pub fn export_booklet(doc: &Document, cache: &Cache, opts: &BookletOptions) -> R
|
||||
story_tags: HashMap::new(),
|
||||
tag_story: None,
|
||||
para_tags: HashMap::new(),
|
||||
in_line_span: false,
|
||||
to_unicode: HashMap::new(),
|
||||
};
|
||||
let pairs = booklet_pairs(n, opts.kind);
|
||||
// Sheet size from the largest page.
|
||||
@@ -470,6 +474,11 @@ pub(crate) struct Exporter<'a> {
|
||||
pub(crate) tag_story: Option<designcraft_doc::StoryId>,
|
||||
/// Marked content of each (story, paragraph index).
|
||||
pub(crate) para_tags: HashMap<(designcraft_doc::StoryId, usize), Vec<Identifier>>,
|
||||
/// While a line's ActualText span is open (no marked content may open inside it).
|
||||
pub(crate) in_line_span: bool,
|
||||
/// The text krilla maps each embedded glyph to. It keeps a glyph's first mapping and wraps a
|
||||
/// glyph drawn with other text in an ActualText span.
|
||||
pub(crate) to_unicode: HashMap<(krilla::text::Font, u32), String>,
|
||||
}
|
||||
|
||||
enum TagEntry {
|
||||
@@ -1579,6 +1588,8 @@ mod image_content_cache_tests {
|
||||
story_tags: HashMap::new(),
|
||||
tag_story: None,
|
||||
para_tags: HashMap::new(),
|
||||
in_line_span: false,
|
||||
to_unicode: HashMap::new(),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
|
||||
@@ -168,6 +168,7 @@ fn kashidas_extract_as_no_text() {
|
||||
let bytes = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap();
|
||||
let words: Vec<String> = extract_text(&bytes)[0].split([' ', '\u{FFFD}']).filter(|w| !w.is_empty()).map(str::to_string).collect();
|
||||
assert!(words.iter().all(|w| w == "بسم" || w == "الله"), "{words:?}");
|
||||
assert_eq!(opened_inside_actual_text(&bytes), 0);
|
||||
let spans = line_actual_texts(&bytes);
|
||||
assert!(spans.len() > 1 && spans.iter().all(|t| !t.contains('\u{640}') && text.contains(t.trim())), "{spans:?}");
|
||||
}
|
||||
@@ -188,3 +189,48 @@ fn arabic_ligatures_extract_their_letters() {
|
||||
assert_eq!(extract_text(&bytes)[0], text);
|
||||
assert_eq!(line_actual_texts(&bytes), [text]);
|
||||
}
|
||||
|
||||
/// How many marked-content sections the first page opens while an ActualText span is open.
|
||||
/// Viewers such as poppler drop the text before an ActualText span nested in another.
|
||||
fn opened_inside_actual_text(bytes: &[u8]) -> usize {
|
||||
use hayro_syntax::content::ops::TypedInstruction;
|
||||
use hayro_syntax::object::Object;
|
||||
let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).expect("parse");
|
||||
let page = &pdf.pages()[0];
|
||||
let (mut open, mut nested) = (Vec::new(), 0);
|
||||
let mut ops = page.typed_operations();
|
||||
while let Some(op) = ops.next() {
|
||||
let actual = match op {
|
||||
TypedInstruction::BeginMarkedContentWithProperties(op) => matches!(op.1, Object::Dict(d) if d.contains_key(b"ActualText")),
|
||||
TypedInstruction::BeginMarkedContent(_) => false,
|
||||
TypedInstruction::EndMarkedContent(_) => {
|
||||
open.pop();
|
||||
continue;
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
nested += usize::from(open.contains(&true));
|
||||
open.push(actual);
|
||||
}
|
||||
nested
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_clusters_in_right_to_left_lines_open_no_span_of_their_own() {
|
||||
// Shadda clusters on contextual forms and pointed letters, in a system font with Arabic and
|
||||
// Hebrew (skipped without one): krilla gives such clusters an ActualText span of their own.
|
||||
let lines = ["سيّد الكلّ مُحَمَّد", "ישראל אַבְרָהָם יִצְחָק"];
|
||||
let mut d = doc(&lines.join("\n"), designcraft_fonts::DEFAULT_FAMILY, Rect::new(36.0, 36.0, 576.0, 300.0), rtl());
|
||||
d.settings.glyph_fallback = true;
|
||||
let sid = *d.stories.keys().next().unwrap();
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let glyphs: Vec<_> = cs.frames[0].lines.iter().flat_map(|l| &l.glyphs).collect();
|
||||
if glyphs.iter().any(|g| g.gid == 0) || !glyphs.windows(2).any(|w| w[1].len == 0 && w[1].byte == w[0].byte) {
|
||||
return;
|
||||
}
|
||||
for tagged in [false, true] {
|
||||
let bytes = export_pdf(&d, &Cache::new(), &PdfOptions { tagged, ..PdfOptions::default() }).unwrap();
|
||||
assert_eq!(opened_inside_actual_text(&bytes), 0, "tagged: {tagged}");
|
||||
assert_eq!(line_actual_texts(&bytes), lines, "tagged: {tagged}");
|
||||
}
|
||||
}
|
||||
|
||||
+88
-18
@@ -184,17 +184,18 @@ impl Exporter<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A glyph run, tagged under its paragraph while a story is being tagged.
|
||||
/// A glyph run, tagged under its paragraph while a story is being tagged (inside a line span,
|
||||
/// the span is its tag: marked content can't nest).
|
||||
fn tagged_run(&mut self, s: &mut Surface, cs: &ComposedStory, gs: &[PlacedGlyph], baseline: f64, story: &str) {
|
||||
match self.tag_story {
|
||||
Some(sid) => {
|
||||
Some(sid) if !self.in_line_span => {
|
||||
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),
|
||||
_ => self.run(s, cs, gs, baseline, story),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,6 +225,7 @@ impl Exporter<'_> {
|
||||
for l in &ft.lines {
|
||||
let gs = &l.glyphs;
|
||||
let line_span = self.start_rtl_line(s, cs, ft, gs, story);
|
||||
self.in_line_span = line_span;
|
||||
let mut i = 0;
|
||||
while i < gs.len() {
|
||||
let g = &gs[i];
|
||||
@@ -251,6 +253,7 @@ impl Exporter<'_> {
|
||||
}
|
||||
if line_span {
|
||||
s.end_tagged();
|
||||
self.in_line_span = false;
|
||||
}
|
||||
}
|
||||
self.as_artifact(s, |me, s| {
|
||||
@@ -443,30 +446,89 @@ impl Exporter<'_> {
|
||||
};
|
||||
let (text, mut ranges) = self.run_text(glyphs, story);
|
||||
let order = split_clusters(glyphs, &text, &mut ranges);
|
||||
let (text, ranges, cuts) = if self.in_line_span {
|
||||
self.line_span_text(&font, glyphs, &order, &text, &ranges)
|
||||
} else {
|
||||
self.note_unicode(&font, order.iter().filter_map(|&i| Some((glyphs.get(i)?.gid, ranges.get(i)?.clone()))), &text);
|
||||
(text, ranges, Vec::new())
|
||||
};
|
||||
let k = 1.0 / (hs * size);
|
||||
// Glyphs are placed absolutely: each advance reaches the next glyph drawn, a vertical offset
|
||||
// (a mark above or below its base) is undone by the skew so it lands where it was composed.
|
||||
let kg: Vec<KrillaGlyph> = order
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(n, &i)| {
|
||||
let g = glyphs.get(i)?;
|
||||
let next = order.get(n + 1).and_then(|&m| glyphs.get(m)).map_or(g.x + g.adv, |m| m.x);
|
||||
let dy = g.y - g0.y;
|
||||
let r = ranges.get(i)?.clone();
|
||||
Some(KrillaGlyph::new(GlyphId::new(g.gid), ((next - g.x) * k) as f32, (tan * dy * k) as f32, (-dy / size) as f32, 0.0, r, None))
|
||||
})
|
||||
.collect();
|
||||
let start = order.first().and_then(|&i| glyphs.get(i)).map_or(0.0, |g| (g.x - g0.x) / hs);
|
||||
let glyph = |n: usize| {
|
||||
let g = glyphs.get(*order.get(n)?)?;
|
||||
let next = order.get(n + 1).and_then(|&m| glyphs.get(m)).map_or(g.x + g.adv, |m| m.x);
|
||||
let dy = g.y - g0.y;
|
||||
let r = ranges.get(*order.get(n)?)?.clone();
|
||||
Some(KrillaGlyph::new(GlyphId::new(g.gid), ((next - g.x) * k) as f32, (tan * dy * k) as f32, (-dy / size) as f32, 0.0, r, None))
|
||||
};
|
||||
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(start as f32, 0.0), &kg, font, &text, size as f32, false);
|
||||
let mut from = 0;
|
||||
for to in cuts.into_iter().chain([order.len()]) {
|
||||
let kg: Vec<KrillaGlyph> = (from..to).filter_map(glyph).collect();
|
||||
let start = order.get(from).and_then(|&i| glyphs.get(i)).map_or(0.0, |g| (g.x - g0.x) / hs);
|
||||
s.draw_glyphs(Point::from_xy(start as f32, 0.0), &kg, font.clone(), &text, size as f32, false);
|
||||
from = to;
|
||||
}
|
||||
s.set_fill(None);
|
||||
s.set_stroke(None);
|
||||
s.pop();
|
||||
}
|
||||
|
||||
/// Record what krilla maps the glyphs of one draw call to, given in drawing order: the first
|
||||
/// text a glyph is drawn with, except on the glyphs of a cluster after its first. (krilla maps
|
||||
/// every glyph of a cluster only for PDF/A-a, -u and PDF/UA, which exports don't use.)
|
||||
fn note_unicode(&mut self, font: &Font, glyphs: impl IntoIterator<Item = (u32, Range<usize>)>, text: &str) {
|
||||
let mut prev = None;
|
||||
for (gid, r) in glyphs {
|
||||
if prev.as_ref() != Some(&r)
|
||||
&& let Some(t) = text.get(r.clone())
|
||||
{
|
||||
self.to_unicode.entry((font.clone(), gid)).or_insert_with(|| t.to_string());
|
||||
}
|
||||
prev = Some(r);
|
||||
}
|
||||
}
|
||||
|
||||
/// Text for a run drawn inside a line span, where krilla must not open ActualText spans of its
|
||||
/// own (it does for a glyph drawn with other text than its font maps it to, and for glyphs in a
|
||||
/// row that share their text). Each glyph gets the text its font maps it to, else its own text,
|
||||
/// a cluster's text going to its first glyph. Returns the text, each glyph's range in it, and
|
||||
/// the positions in `order` where the run must be split into separate draw calls because two
|
||||
/// glyphs in a row have the same empty range.
|
||||
fn line_span_text(
|
||||
&mut self,
|
||||
font: &Font,
|
||||
glyphs: &[PlacedGlyph],
|
||||
order: &[usize],
|
||||
text: &str,
|
||||
ranges: &[Range<usize>],
|
||||
) -> (String, Vec<Range<usize>>, Vec<usize>) {
|
||||
let mut out = String::new();
|
||||
let mut out_ranges = vec![0..0; glyphs.len()];
|
||||
let mut cuts = Vec::new();
|
||||
let (mut prev, mut prev_out) = (None, None);
|
||||
for (n, &i) in order.iter().enumerate() {
|
||||
let (Some(g), Some(r)) = (glyphs.get(i), ranges.get(i)) else { continue };
|
||||
let own = if prev == Some(r) { "" } else { text.get(r.clone()).unwrap_or_default() };
|
||||
let mapped = self.to_unicode.entry((font.clone(), g.gid)).or_insert_with(|| own.to_string());
|
||||
let a = out.len();
|
||||
out.push_str(mapped);
|
||||
let range = a..out.len();
|
||||
if prev_out.as_ref() == Some(&range) {
|
||||
cuts.push(n);
|
||||
}
|
||||
if let Some(slot) = out_ranges.get_mut(i) {
|
||||
*slot = range.clone();
|
||||
}
|
||||
prev = Some(r);
|
||||
prev_out = Some(range);
|
||||
}
|
||||
(out, out_ranges, cuts)
|
||||
}
|
||||
|
||||
/// Fallback for faces krilla can't embed: glyph outlines as paths.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn outline_run(
|
||||
@@ -507,23 +569,31 @@ impl Exporter<'_> {
|
||||
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
|
||||
let ranges: Vec<(u32, Range<usize>)> = 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() };
|
||||
(g.gid, g.cluster..end)
|
||||
})
|
||||
.collect();
|
||||
let glyphs: Vec<KrillaGlyph> = shaped
|
||||
.iter()
|
||||
.zip(&ranges)
|
||||
.map(|(g, (_, r))| {
|
||||
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,
|
||||
r.clone(),
|
||||
None,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
self.note_unicode(&font, ranges, text);
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user