diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index d92fc242b..6b31510fe 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -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, /// Marked content of each (story, paragraph index). pub(crate) para_tags: HashMap<(designcraft_doc::StoryId, usize), Vec>, + /// 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] diff --git a/crates/pdf/src/rtl_tests.rs b/crates/pdf/src/rtl_tests.rs index fc38747a7..9414e870b 100644 --- a/crates/pdf/src/rtl_tests.rs +++ b/crates/pdf/src/rtl_tests.rs @@ -168,6 +168,7 @@ fn kashidas_extract_as_no_text() { let bytes = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap(); let words: Vec = 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}"); + } +} diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index e500f8bce..1f4207e31 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -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 = 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 = (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)>, 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], + ) -> (String, Vec>, Vec) { + 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 = shaped + let ranges: Vec<(u32, Range)> = 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 = 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);