diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 6b31510fe..d92fc242b 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -162,8 +162,6 @@ 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)"); @@ -341,8 +339,6 @@ 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. @@ -474,11 +470,6 @@ 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 { @@ -1588,8 +1579,6 @@ 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 9414e870b..c2ec5bb96 100644 --- a/crates/pdf/src/rtl_tests.rs +++ b/crates/pdf/src/rtl_tests.rs @@ -1,6 +1,6 @@ //! Right-to-left text in exported PDFs: content streams list it in reading order with marks after -//! their letters, lines with right-to-left text carry their reading-order text as ActualText, and -//! glyphs stay where composition put them. +//! their letters, ActualText never spans more than a cluster, and glyphs stay where composition +//! put them. use designcraft_compose::{Cache, ComposeOptions, compose_story}; use designcraft_doc::build::NewDocument; @@ -45,8 +45,8 @@ fn rtl() -> ParaAttrs { ParaAttrs { direction: Some(TextDirection::RightToLeft), ..ParaAttrs::default() } } -/// The ActualText of every line span (marked content with an MCID) on the first page. -fn line_actual_texts(bytes: &[u8]) -> Vec { +/// The ActualText of every marked-content span on the first page. +fn actual_texts(bytes: &[u8]) -> Vec { use hayro_syntax::content::ops::TypedInstruction; use hayro_syntax::object::{Dict, Object}; let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).expect("parse"); @@ -57,9 +57,6 @@ fn line_actual_texts(bytes: &[u8]) -> Vec { let TypedInstruction::BeginMarkedContentWithProperties(op) = op else { continue }; let Object::Dict(props) = op.1 else { continue }; let props: &Dict<'_> = props; - if !props.contains_key(b"MCID") { - continue; - } let Some(s) = props.get::(b"ActualText") else { continue }; let units: Vec = s.as_bytes().chunks_exact(2).map(|b| u16::from_be_bytes([b[0], b[1]])).collect(); let text = String::from_utf16(&units).expect("UTF-16"); @@ -76,11 +73,9 @@ fn hebrew_extracts_in_reading_order_with_points_on_their_letters() { let bytes = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap(); // Glyph by glyph (ToUnicode, content order): each point right after its letter, once. assert_eq!(extract_text(&bytes)[0], lines.concat()); - // Viewers that order text by position read each line's ActualText. - assert_eq!(line_actual_texts(&bytes), lines); - // Tagged, the span is the line's tag under its paragraph. + assert!(actual_texts(&bytes).is_empty()); let tagged = export_pdf(&d, &Cache::new(), &PdfOptions { tagged: true, ..PdfOptions::default() }).unwrap(); - assert_eq!(line_actual_texts(&tagged), lines); + assert!(actual_texts(&tagged).is_empty()); assert_eq!(extract_text(&tagged)[0], lines.concat()); } @@ -91,10 +86,7 @@ fn hebrew_words_in_left_to_right_lines_extract_in_reading_order() { let d = doc(text, HEBREW, Rect::new(36.0, 36.0, 576.0, 300.0), ParaAttrs::default()); let bytes = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap(); assert_eq!(extract_text(&bytes)[0], text); - assert_eq!(line_actual_texts(&bytes), [text]); - // A line without right-to-left text has no line span. - let d = doc("abc def", HEBREW, Rect::new(36.0, 36.0, 576.0, 300.0), ParaAttrs::default()); - assert!(line_actual_texts(&export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap()).is_empty()); + assert!(actual_texts(&bytes).is_empty()); } /// Device origin of every glyph drawn on the first page. @@ -168,9 +160,8 @@ 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:?}"); + let spans = actual_texts(&bytes); + assert!(spans.iter().all(|t| !t.contains('\u{640}') && !t.contains(' ')), "{spans:?}"); } #[test] @@ -187,7 +178,6 @@ fn arabic_ligatures_extract_their_letters() { } let bytes = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap(); 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. @@ -216,9 +206,10 @@ fn opened_inside_actual_text(bytes: &[u8]) -> usize { } #[test] -fn mark_clusters_in_right_to_left_lines_open_no_span_of_their_own() { +fn right_to_left_lines_carry_actual_text_only_on_their_clusters() { // 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. + // Hebrew (skipped without one): krilla gives such clusters an ActualText span of their own, + // which must not nest in another (poppler drops the text before a nested one). 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; @@ -231,6 +222,7 @@ fn mark_clusters_in_right_to_left_lines_open_no_span_of_their_own() { 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}"); + let spans = actual_texts(&bytes); + assert!(!spans.is_empty() && spans.iter().all(|t| !t.contains(' ') && t.chars().count() <= 3), "tagged: {tagged}: {spans:?}"); } } diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index 1f4207e31..719f4ee92 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -7,8 +7,10 @@ //! can select, search and copy it. //! //! Composed lines hold their glyphs in text order, so content streams list text in reading order -//! even where right-to-left text is drawn leftwards. Viewers that order text by position instead -//! read the ActualText that a line with right-to-left text carries. +//! even where right-to-left text is drawn leftwards. Right-to-left lines and runs carry no +//! ActualText of their own: poppler orders right-to-left glyphs by itself, but it positions an +//! ActualText with the transform in force where its span ends (losing a span over several runs) +//! and reverses the characters of one in a right-to-left line. use std::collections::HashMap; use std::ops::Range; @@ -184,18 +186,17 @@ impl Exporter<'_> { } } - /// 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). + /// 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) if !self.in_line_span => { + 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); } - _ => self.run(s, cs, gs, baseline, story), + None => self.run(s, cs, gs, baseline, story), } } @@ -224,8 +225,6 @@ 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]; @@ -244,17 +243,9 @@ impl Exporter<'_> { continue; } let j = run_end(ft, gs, i); - if line_span { - self.run(s, cs, &gs[i..j], l.baseline, story); - } else { - self.tagged_run(s, cs, &gs[i..j], l.baseline, story); - } + self.tagged_run(s, cs, &gs[i..j], l.baseline, story); i = j; } - if line_span { - s.end_tagged(); - self.in_line_span = false; - } } self.as_artifact(s, |me, s| { for (rule, r) in text_rules(cs, ft, false) { @@ -272,47 +263,6 @@ impl Exporter<'_> { }); } - /// Open a span over a line with right-to-left text whose ActualText is the line's text in - /// reading order (tagged under its paragraph while a story is being tagged). Viewers that order - /// text by position would otherwise read it in visual order. Marked content can't nest, so only - /// in untagged exports and in tagged stories (not inside artifacts or figures). - fn start_rtl_line(&mut self, s: &mut Surface, cs: &ComposedStory, ft: &FrameText, gs: &[PlacedGlyph], story: &str) -> bool { - if ft.vertical || !gs.iter().any(|g| g.rtl && g.visible) || (self.opts.tagged && self.tag_story.is_none()) { - return false; - } - let mut text = String::new(); - 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; - } - let j = run_end(ft, gs, i); - // Only runs `run` draws. - let painted = cs.styles.get(g.style as usize).is_some_and(|st| { - self.swatch_color(&st.fill, st.fill_tint).is_some() - || (st.stroke != designcraft_color::swatch::NONE - && st.stroke_weight > 0.0 - && self.swatch_color(&st.stroke, st.stroke_tint).is_some()) - }); - if painted && let Some(run) = gs.get(i..j) { - text.push_str(&self.run_text(run, story).0); - } - i = j; - } - if text.is_empty() { - return false; - } - let id = s.start_tagged(ContentTag::Span(SpanTag::empty().with_actual_text(Some(&text)))); - if let Some(sid) = self.tag_story { - let byte = gs.first().map_or(0, |g| g.byte); - let pi = self.doc.story(sid).map_or(0, |st| st.para_at(byte.min(st.len()))); - self.para_tags.entry((sid, pi)).or_default().push(id); - } - true - } - /// 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 { @@ -446,89 +396,30 @@ 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 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)) - }; + 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); 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() })); - 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.draw_glyphs(Point::from_xy(start as f32, 0.0), &kg, font, &text, size as f32, false); 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( @@ -569,31 +460,23 @@ 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 ranges: Vec<(u32, Range)> = shaped + let glyphs: Vec = 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, - r.clone(), + g.cluster..end, 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);