diff --git a/crates/edit/src/indic.rs b/crates/edit/src/indic.rs new file mode 100644 index 000000000..2aec8f01e --- /dev/null +++ b/crates/edit/src/indic.rs @@ -0,0 +1,258 @@ +//! Visual and logical order for Indic scripts in Edit text (Devanagari, Bengali, Gurmukhi, Gujarati, Oriya, Tamil, Malayalam). +//! +//! Legacy PDF producers often output glyphs in *visual* display order when using fonts without +//! OpenType GSUB layout tables. In visual order, pre-base vowel signs (such as Devanagari short 'i' +//! `ि` U+093F) appear in the stream *before* the consonant cluster they modify, matching their visual +//! placement to the left of the glyph. +//! +//! Standard Unicode text is *logical* (typing order): the pre-base vowel sign follows the consonant +//! cluster. The editor presents and edits text in logical order. When a source font is flagged with +//! `visual_order`, [`to_logical`] converts extracted stream text to logical order for editing, and +//! [`to_visual`] converts logical edits back into the visual glyph sequence the font expects. + +/// Whether `c` is an Indic consonant across covered scripts. +pub fn is_indic_consonant(c: char) -> bool { + matches!( + c, + '\u{0915}'..='\u{0939}' | '\u{0958}'..='\u{095F}' | '\u{0978}'..='\u{097F}' // Devanagari + | '\u{0995}'..='\u{09B9}' | '\u{09DC}'..='\u{09DF}' // Bengali + | '\u{0A15}'..='\u{0A39}' | '\u{0A59}'..='\u{0A5E}' // Gurmukhi + | '\u{0A95}'..='\u{0AB9}' // Gujarati + | '\u{0B15}'..='\u{0B39}' | '\u{0B5C}'..='\u{0B5F}' // Oriya + | '\u{0B95}'..='\u{0BB9}' // Tamil + | '\u{0C15}'..='\u{0C39}' | '\u{0C58}'..='\u{0C5A}' // Telugu + | '\u{0C95}'..='\u{0CB9}' | '\u{0CDE}' // Kannada + | '\u{0D15}'..='\u{0D3A}' // Malayalam + ) +} + +/// Whether `c` is an Indic nukta (diacritic dot modifying a consonant). +pub fn is_indic_nukta(c: char) -> bool { + matches!( + c, + '\u{093C}' // Devanagari + | '\u{09BC}' // Bengali + | '\u{0A3C}' // Gurmukhi + | '\u{0ABC}' // Gujarati + | '\u{0B3C}' // Oriya + ) +} + +/// Whether `c` is an Indic virama (halant / vowel killer). +pub fn is_indic_virama(c: char) -> bool { + matches!( + c, + '\u{094D}' // Devanagari + | '\u{09CD}' // Bengali + | '\u{0A4D}' // Gurmukhi + | '\u{0ACD}' // Gujarati + | '\u{0B4D}' // Oriya + | '\u{0BCD}' // Tamil + | '\u{0C4D}' // Telugu + | '\u{0CCD}' // Kannada + | '\u{0D4D}' // Malayalam + ) +} + +/// Whether `c` is an Indic pre-base vowel sign that appears visually to the left of its consonant. +pub fn is_indic_pre_base_vowel(c: char) -> bool { + matches!( + c, + '\u{093F}' // Devanagari short I + | '\u{09BF}' | '\u{09C7}' | '\u{09C8}' // Bengali short I, E, AI + | '\u{0A3F}' // Gurmukhi short I + | '\u{0ABF}' // Gujarati short I + | '\u{0B3F}' // Oriya short I + | '\u{0BC6}' | '\u{0BC7}' | '\u{0BC8}' // Tamil E, EE, AI + | '\u{0D46}' | '\u{0D47}' | '\u{0D48}' // Malayalam E, EE, AI + ) +} + +/// Modifiers that attach to an Indic vowel sign (such as candrabindu, anusvara, or visarga). +fn is_indic_vowel_modifier(c: char) -> bool { + matches!( + c, + '\u{0901}'..='\u{0903}' // Devanagari candrabindu, anusvara, visarga + | '\u{0981}'..='\u{0983}' // Bengali + | '\u{0A01}'..='\u{0A03}' // Gurmukhi + | '\u{0A81}'..='\u{0A83}' // Gujarati + | '\u{0B01}'..='\u{0B03}' // Oriya + ) +} + +/// The character at `i`, if any. +fn at(chars: &[char], i: usize) -> Option { + chars.get(i).copied() +} + +/// The end of the consonant cluster starting at `start` (consonants with their nuktas, joined by +/// viramas and optional ZWNJ/ZWJ); `start` itself when no consonant starts there. +fn cluster_end(chars: &[char], start: usize) -> usize { + let mut end = start; + while at(chars, end).is_some_and(is_indic_consonant) { + end += 1; + if at(chars, end).is_some_and(is_indic_nukta) { + end += 1; + } + if at(chars, end).is_some_and(is_indic_virama) { + end += 1; + if at(chars, end).is_some_and(|c| matches!(c, '\u{200C}' | '\u{200D}')) { + end += 1; + } + if at(chars, end).is_some_and(is_indic_consonant) { + continue; + } + } + break; + } + end +} + +/// The end of the vowel-modifier run (candrabindu, anusvara, visarga) starting at `start`. +fn modifiers_end(chars: &[char], start: usize) -> usize { + let mut end = start; + while at(chars, end).is_some_and(is_indic_vowel_modifier) { + end += 1; + } + end +} + +/// Convert Indic text from visual glyph order (pre-base vowels before their consonant cluster) +/// to standard Unicode logical order. +pub fn to_logical(text: &str) -> String { + let chars: Vec = text.chars().collect(); + let mut out: Vec = Vec::with_capacity(chars.len()); + let mut i = 0; + while let Some(ch) = at(&chars, i) { + if is_indic_pre_base_vowel(ch) { + let v_end = modifiers_end(&chars, i + 1); + let c_end = cluster_end(&chars, v_end); + if c_end > v_end { + out.extend_from_slice(chars.get(v_end..c_end).unwrap_or_default()); + out.extend_from_slice(chars.get(i..v_end).unwrap_or_default()); + i = c_end; + continue; + } + } + out.push(ch); + i += 1; + } + out.into_iter().collect() +} + +/// Convert Indic text from standard Unicode logical order to visual display order for fonts +/// that expect pre-base vowels before their consonant cluster. +pub fn to_visual(text: &str) -> String { + let chars: Vec = text.chars().collect(); + let mut out: Vec = Vec::with_capacity(chars.len()); + let mut i = 0; + while let Some(ch) = at(&chars, i) { + if is_indic_consonant(ch) { + let c_end = cluster_end(&chars, i); + if at(&chars, c_end).is_some_and(is_indic_pre_base_vowel) { + let v_end = modifiers_end(&chars, c_end + 1); + out.extend_from_slice(chars.get(c_end..v_end).unwrap_or_default()); + out.extend_from_slice(chars.get(i..c_end).unwrap_or_default()); + i = v_end; + } else { + out.extend_from_slice(chars.get(i..c_end).unwrap_or_default()); + i = c_end; + } + continue; + } + out.push(ch); + i += 1; + } + out.into_iter().collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn simple_words_roundtrip() { + // किताब (kitab): k + i + t + aa + b + let visual = "\u{093F}\u{0915}\u{0924}\u{093E}\u{092C}"; + let logical = "\u{0915}\u{093F}\u{0924}\u{093E}\u{092C}"; + assert_eq!(to_logical(visual), logical); + assert_eq!(to_visual(logical), visual); + + // दिन (din): d + i + n + let visual_din = "\u{093F}\u{0926}\u{0928}"; + let logical_din = "\u{0926}\u{093F}\u{0928}"; + assert_eq!(to_logical(visual_din), logical_din); + assert_eq!(to_visual(logical_din), visual_din); + } + + #[test] + fn decomposed_nukta_with_pre_base_vowel() { + // Decomposed ड़ि: DDA (U+0921) + NUKTA (U+093C) + Short I (U+093F) + let logical = "\u{0921}\u{093C}\u{093F}"; + let visual = "\u{093F}\u{0921}\u{093C}"; + assert_eq!(to_logical(visual), logical); + assert_eq!(to_visual(logical), visual); + + // Decomposed ढ़ि: DDHA (U+0922) + NUKTA (U+093C) + Short I (U+093F) + let logical_ddhi = "\u{0922}\u{093C}\u{093F}"; + let visual_ddhi = "\u{093F}\u{0922}\u{093C}"; + assert_eq!(to_logical(visual_ddhi), logical_ddhi); + assert_eq!(to_visual(logical_ddhi), visual_ddhi); + } + + #[test] + fn precomposed_nukta_with_pre_base_vowel() { + // Precomposed ड़ि (U+095C + U+093F) + let logical = "\u{095C}\u{093F}"; + let visual = "\u{093F}\u{095C}"; + assert_eq!(to_logical(visual), logical); + assert_eq!(to_visual(logical), visual); + } + + #[test] + fn full_words_with_nukta_and_pre_base_vowel() { + // बढ़िया (badhiya): ba + ddha + nukta + short_i + ya + aa + let logical = "\u{092C}\u{0922}\u{093C}\u{093F}\u{092F}\u{093E}"; + let visual = "\u{092C}\u{093F}\u{0922}\u{093C}\u{092F}\u{093E}"; + assert_eq!(to_logical(visual), logical); + assert_eq!(to_visual(logical), visual); + + // खिड़की (khirki): kha + short_i + dda + nukta + ka + long_ii + let logical_khirki = "\u{0916}\u{093F}\u{0921}\u{093C}\u{0915}\u{0940}"; + let visual_khirki = "\u{093F}\u{0916}\u{0921}\u{093C}\u{0915}\u{0940}"; + assert_eq!(to_logical(visual_khirki), logical_khirki); + assert_eq!(to_visual(logical_khirki), visual_khirki); + } + + #[test] + fn complex_conjuncts_with_pre_base_vowel() { + // स्थिति (sthiti): sa + virama + tha + short_i + ta + short_i + let logical = "\u{0938}\u{094D}\u{0925}\u{093F}\u{0924}\u{093F}"; + let visual = "\u{093F}\u{0938}\u{094D}\u{0925}\u{093F}\u{0924}"; + assert_eq!(to_logical(visual), logical); + assert_eq!(to_visual(logical), visual); + } + + #[test] + fn other_indic_scripts_pre_base_vowels() { + // Bengali কি (ki): KA (U+0995) + I (U+09BF) + let b_logical = "\u{0995}\u{09BF}"; + let b_visual = "\u{09BF}\u{0995}"; + assert_eq!(to_logical(b_visual), b_logical); + assert_eq!(to_visual(b_logical), b_visual); + + // Gurmukhi ਕਿ (ki): KA (U+0A15) + I (U+0A3F) + let g_logical = "\u{0A15}\u{0A3F}"; + let g_visual = "\u{0A3F}\u{0A15}"; + assert_eq!(to_logical(g_visual), g_logical); + assert_eq!(to_visual(g_logical), g_visual); + } + + #[test] + fn non_indic_text_is_untouched() { + for s in ["", "Hello world!", "PDF 123", "日本語テキスト", "123.45"] { + assert_eq!(to_logical(s), s); + assert_eq!(to_visual(s), s); + } + } +} diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 32e0eee92..701ed1118 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -624,6 +624,7 @@ pub use flatten::{flatten, flatten_fill_sign}; pub mod images; pub use images::{ImageChange, PageImage, change_image, page_images, reading_images, rect_to_rect, turn_about_centre}; pub mod bidi; +pub mod indic; pub mod text; pub use text::{ BlockStyle, LineEdit, RangeStyle, TextBlock, TextLine, clear_block, reading_blocks, replace_block, replace_line, rewrite_block, text_blocks, diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index aca5df712..d9cb79a6f 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -3162,3 +3162,94 @@ fn marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite() { } assert_eq!(after[1].text, "Changed row"); } + +#[test] +fn logical_indic_with_nuktas_not_reordered_on_standard_font() { + // Already logical Hindi text with nuktas (बढ़िया) must not be corrupted by pre-base reordering + // when the font does not require visual order. + let hex_actual = ""; + let doc = text_page(&format!("/Span << /ActualText {hex_actual} >> BDC BT /F1 12 Tf 40 500 Td (ab) Tj ET EMC")); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines.len(), 1); + assert_eq!(lines[0].text, "\u{092C}\u{0922}\u{093C}\u{093F}\u{092F}\u{093E}"); + assert!(!lines[0].visual_order); +} + +/// One page in a font named `base_font` whose ToUnicode maps A ब, B ि, C ढ़, D य, E ा, F क, G त. +/// A legacy visual-order font draws "बढ़िया" as `(ABCDE)` (ब ि ढ़ या) and "किताब" as `(BFGEA)`. +fn devanagari_page(base_font: &str, content: &str) -> Document { + let cmap = "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapType 2 def \ + 1 begincodespacerange <00> endcodespacerange 8 beginbfchar \ + <20> <0020> <41> <092C> <42> <093F> <43> <0922093C> <44> <092F> <45> <093E> <46> <0915> <47> <0924> \ + endbfchar endcmap CMapName currentdict /CMap defineresource pop end end"; + let widths: Vec<&str> = (32..=71) + .map(|c| { + if c == 32 { + "250" + } else if c >= 65 { + "500" + } else { + "0" + } + }) + .collect(); + let font = format!( + "<< /Type /Font /Subtype /Type1 /BaseFont /{base_font} /FirstChar 32 /LastChar 71 /Widths [{}] /ToUnicode 6 0 R >>", + widths.join(" ") + ); + build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>", + &stream("", content), + &font, + &stream("", cmap), + ]) +} + +/// A legacy visual-order font (Kruti Dev) whose ToUnicode gives the glyphs in drawing order: the +/// pre-base vowel sign is read back after its consonant, for editing and for reading. +#[test] +fn legacy_indic_font_flags_visual_order_and_reorders_pre_base_vowels() { + let doc = devanagari_page("KrutiDev010", "BT /F1 12 Tf 40 500 Td (ABCDE) Tj ET"); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines.len(), 1); + assert!(lines[0].visual_order); + assert_eq!(lines[0].text, "\u{092C}\u{0922}\u{093C}\u{093F}\u{092F}\u{093E}"); + let reading: Vec = text::reading_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!(reading, ["\u{092C}\u{0922}\u{093C}\u{093F}\u{092F}\u{093E}"]); +} + +/// Typed (logical) text is written back in the font's visual order and reads back as typed. +#[test] +fn legacy_indic_font_edits_are_written_in_visual_order() { + let mut doc = devanagari_page("ABCDEF+KrutiDev010", "BT /F1 12 Tf 40 500 Td (ABCDE) Tj ET"); + let kitab = "\u{0915}\u{093F}\u{0924}\u{093E}\u{092C}"; + assert_eq!(text::replace_line(&mut doc, 0, 0, kitab).unwrap().substituted, None); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert!(content.contains("(BFGEA)"), "{content}"); + assert_eq!(text::text_lines(&reopen(&doc), 0).unwrap()[0].text, kitab); +} + +/// `/ActualText` is already in logical order, even in a visual-order font: "बढ़िया" must not +/// become "बढ़यिा". +#[test] +fn logical_actual_text_in_a_legacy_indic_font_is_not_reordered() { + let doc = devanagari_page("KrutiDev010", "/Span << /ActualText >> BDC BT /F1 12 Tf 40 500 Td (ABCDE) Tj ET EMC"); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines.len(), 1); + assert_eq!(lines[0].text, "\u{092C}\u{0922}\u{093C}\u{093F}\u{092F}\u{093E}"); + let reading: Vec = text::reading_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!(reading, ["\u{092C}\u{0922}\u{093C}\u{093F}\u{092F}\u{093E}"]); +} + +/// A modern subset font with a logical ToUnicode and no GSUB in the PDF (subsets and bare CFF +/// often drop it) is not visual order: its text is read as it is. +#[test] +fn subset_indic_font_without_legacy_name_keeps_logical_text() { + let doc = devanagari_page("ABCDEF+NotoSansDevanagari-Regular", "BT /F1 12 Tf 40 500 Td (ACBDE) Tj ET"); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines.len(), 1); + assert!(!lines[0].visual_order); + assert_eq!(lines[0].text, "\u{092C}\u{0922}\u{093C}\u{093F}\u{092F}\u{093E}"); +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index b437c3ee5..c2412783b 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -42,6 +42,8 @@ pub struct TextLine { /// Whether new text in this font can only be shown by substituting another font (no /// Unicode mapping for the line, so nothing could be reused). pub decodable: bool, + /// Whether the font requires visual-order Indic text. + pub visual_order: bool, /// The Japanese or Hebrew fallback face the font subsets, when the line is in one (see [`same_font`]). face: Option, /// The content stream piece the line starts in, and its operators (indices into the page's @@ -55,6 +57,9 @@ pub struct TextLine { /// Right-to-left `/ActualText` already supplies typing order; glyph text still needs bidi /// recovery. Left-to-right ActualText (e.g. a Latin ligature) joins its line as usual. logical_order: bool, + /// Some of the line's text is `/ActualText`, which is already in logical order: visual-order + /// Indic recovery would scramble it. + actual_text: bool, } impl TextLine { @@ -473,9 +478,11 @@ struct Shown { size: f64, bold: bool, italic: bool, + visual_order: bool, decodable: bool, scope: Option, logical_order: bool, + actual_text: bool, } /// The graphics state carried from one of a page's content streams to the next: the streams @@ -560,6 +567,7 @@ fn interpret( // out of the line's bidi recovery. Word and Chrome wrap Latin ligatures // (`fi`, `fl`) in ActualText spans mid-word; those keep joining their line. first.logical_order = logical.chars().any(crate::bidi::is_rtl); + first.actual_text = true; first.text = logical; first.start_x = first.rect[0]; first.end_x = first.rect[2]; @@ -716,9 +724,11 @@ fn interpret( size: if size_user > 0.0 { size_user } else { ts.size }, bold: m.bold, italic: m.italic, + visual_order: m.visual_order, decodable, scope: marked.last().copied().flatten(), logical_order: false, + actual_text: false, }); } _ => {} @@ -774,6 +784,8 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro l.ops.push(s.op); l.ops.extend(s.extra_ops.iter().copied()); l.decodable &= s.decodable; + l.visual_order |= s.visual_order; + l.actual_text |= s.actual_text; // Spaces may use a separate subset font; they do not change the visible style. } else { lines.push(TextLine { @@ -786,10 +798,12 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro italic: s.italic, color: fill_color(&s.state.fill), decodable: s.decodable, + visual_order: s.visual_order, face: s.face.clone(), stream: si, scope: s.scope, logical_order: s.logical_order, + actual_text: s.actual_text, ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(), origin: Origin { tm: s.tm.0, @@ -808,6 +822,10 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro } // Content streams draw right-to-left lines in visual order; the editor works in typing order. for l in lines.iter_mut().filter(|l| !l.logical_order) { + // A visual-order Indic font draws pre-base vowel signs before their consonant. + if l.visual_order && !l.actual_text { + l.text = crate::indic::to_logical(&l.text); + } l.text = crate::bidi::to_logical(&l.text); } // Lines of only spaces aren't editable text. @@ -853,6 +871,8 @@ fn add_shown(lines: &mut Vec, last: &mut Option<(usize, f64, f64, f64) l.ops.push(s.op); l.ops.extend(s.extra_ops.iter().copied()); l.decodable &= s.decodable; + l.visual_order |= s.visual_order; + l.actual_text |= s.actual_text; } else { lines.push(TextLine { text: s.text.clone(), @@ -864,10 +884,12 @@ fn add_shown(lines: &mut Vec, last: &mut Option<(usize, f64, f64, f64) italic: s.italic, color: fill_color(&s.state.fill), decodable: s.decodable, + visual_order: s.visual_order, face: s.face.clone(), stream, scope: s.scope, logical_order: s.logical_order, + actual_text: s.actual_text, ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(), origin: Origin { tm: s.tm.0, @@ -996,6 +1018,10 @@ fn reading_lines(doc: &Document, page: usize) -> Result, EditError walk(&mut w, &parse(&data).ops, &res, &mut carry, si); } let mut lines = w.lines; + // A visual-order Indic font draws pre-base vowel signs before their consonant. + for l in lines.iter_mut().filter(|l| l.visual_order && !l.actual_text) { + l.text = crate::indic::to_logical(&l.text); + } lines.retain(|l| !l.text.trim().is_empty()); Ok(lines) } @@ -1380,8 +1406,10 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi let first = target.ops.first().copied().filter(|i| *i < ops.len()).ok_or_else(|| EditError::Invalid("the page's content changed".into()))?; // The line's own font, when it can show every character. let font = fonts_res.get(target.font.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned()).map(|d| Metrics::from_dict(doc, &d)); + let visual_indic = font.as_ref().is_some_and(|m| m.visual_order) || target.visual_order; + let indic_text = if visual_indic { crate::indic::to_visual(&text) } else { text.clone() }; // Typed in logical order; drawn left to right (right-to-left runs reversed). - let shown = crate::bidi::to_visual(&text); + let shown = crate::bidi::to_visual(&indic_text); let reused = font.as_ref().filter(|m| source_encodes(m, &shown)); let mut substituted = None; let mut replacement: Vec = Vec::new(); @@ -1405,9 +1433,10 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi target.origin.state.word_spacing, )?), None => { + let fallback_shown = crate::bidi::to_visual(&text); if needs_type3(&text) { let fallback = type3_font(doc, &mut fonts_res, &text, source_family(&target.base_font), target.bold, max_glyphs)?; - let runs = type3_encode(&fallback, &shown) + let runs = type3_encode(&fallback, &fallback_shown) .ok_or_else(|| EditError::Invalid(format!("\"{text}\" can't be shown by the {} fallback", fallback.family)))?; let size = font_size_before(&ops, first).unwrap_or(target.size); for (name, bytes) in runs { @@ -1417,10 +1446,10 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi replacement.push(Op::new("Tf", vec![Object::name(&target.font), pdfcraft_content::num(size)])); substituted = Some(format!("{} Type3", fallback.family)); } else { - let win = pdfcraft_fonts::win_ansi(&shown); + let win = pdfcraft_fonts::win_ansi(&fallback_shown); // WinAnsi turns what it can't show into '?'; refuse rather than print the wrong thing. let back: String = win.iter().map(|b| char::from_u32(u32::from(*b)).unwrap_or('?')).collect(); - if shown.chars().zip(back.chars()).any(|(a, b)| b == '?' && a != '?') { + if fallback_shown.chars().zip(back.chars()).any(|(a, b)| b == '?' && a != '?') { return Err(EditError::Invalid(format!("\"{text}\" has characters neither {} nor Helvetica can show", target.base_font))); } // The size in text space: the current Tf's size. @@ -1654,7 +1683,9 @@ pub(crate) fn rewrite_block_with_limit( "formatting part of right-to-left (Hebrew) text, or choosing a document font for it, isn't supported yet".into(), )); } + let visual_indic = members.iter().any(|line| line.visual_order); if !hebrew + && !visual_indic && (!style.ranges.is_empty() || style.resource.is_some() || (text.is_some_and(|draft| { @@ -1688,7 +1719,9 @@ pub(crate) fn rewrite_block_with_limit( } let o_state = ts_state.clone(); let metrics = fonts_res.get(font_name.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned()).map(|d| Metrics::from_dict(doc, &d)); - let reuse = style.family.is_none() && style.bold.is_none() && metrics.as_ref().is_some_and(|m| source_encodes(m, &text)); + let visual_indic = metrics.as_ref().is_some_and(|m| m.visual_order) || first.visual_order; + let indic_text = if visual_indic { crate::indic::to_visual(&text) } else { text.clone() }; + let reuse = style.family.is_none() && style.bold.is_none() && metrics.as_ref().is_some_and(|m| source_encodes(m, &indic_text)); let space = metrics.as_ref().map_or(0.33, |m| source_space(doc, &fonts_res, m)); // The chosen style also determines the real Japanese fallback outlines and advances. let (family, bold, italic) = style.family.unwrap_or((source_family(&b.base_font), b.bold, b.italic)); @@ -1739,7 +1772,10 @@ pub(crate) fn rewrite_block_with_limit( let type3 = if !reuse && needs_type3(&text) { Some(type3_font(doc, &mut fonts_res, &text, family, bold, max_glyphs)?) } else { None }; let advance = |s: &str| -> f64 { let t = match (&metrics, reuse) { - (Some(m), true) => source_width(m, s, size, space, o_state.char_spacing, o_state.word_spacing), + (Some(m), true) => { + let visual_s = if m.visual_order { crate::indic::to_visual(s) } else { s.to_owned() }; + source_width(m, &visual_s, size, space, o_state.char_spacing, o_state.word_spacing) + } _ if let Some(fallback) = &type3 => { // Each character's count in `s`, from one pass over `s`. let mut counts: HashMap = HashMap::new(); @@ -1846,7 +1882,8 @@ pub(crate) fn rewrite_block_with_limit( // Each wrapped line in typing order; drawn left to right (right-to-left runs reversed). let visual = crate::bidi::to_visual(line); if let Some(metrics) = metrics.as_ref().filter(|_| reuse) { - block_ops.extend(source_show(metrics, &visual, size, space, o_state.char_spacing, tw)?); + let indic_line = if metrics.visual_order { crate::indic::to_visual(&visual) } else { visual }; + block_ops.extend(source_show(metrics, &indic_line, size, space, o_state.char_spacing, tw)?); } else { for (font, bytes) in encode(&visual).ok_or_else(|| EditError::Invalid(format!("\"{line}\" can't be shown")))? { if font != shown_font { diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 26d32780f..453351bcc 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -55,6 +55,9 @@ pub struct Metrics { /// font name and, when present, `/FontDescriptor` flags/weight/angle. pub bold: bool, pub italic: bool, + /// Whether this font draws Indic text in visual order (pre-base vowel signs before their + /// consonant): a known legacy Indic font whose Unicode mapping is Indic. See [`legacy_indic_font`]. + pub visual_order: bool, /// Bytes per code for writing new text (1 for simple fonts, else the codespace length). code_len: usize, /// Valid single-character explicit mappings, preindexed once for large full CJK fonts. @@ -71,6 +74,16 @@ fn dict(doc: &Document, o: Option<&Object>) -> Option { o.and_then(|o| doc.resolve(o).as_dict().cloned()) } +/// Whether `base_font` (subset tag allowed) names a legacy Indic font family that is laid out in +/// visual order (Kruti Dev, DevLys, Chanakya, Shusha, Shivaji, Walkman, Akruti, APS-DV, DV-TT…). +fn legacy_indic_font(base_font: &str) -> bool { + let name = + base_font.split_once('+').filter(|(tag, _)| tag.len() == 6 && tag.bytes().all(|b| b.is_ascii_uppercase())).map_or(base_font, |(_, n)| n); + let name = name.to_ascii_lowercase(); + ["kruti", "devlys", "chanakya", "shusha", "shivaji", "walkman", "akruti"].iter().any(|n| name.contains(n)) + || ["aps-", "dv-"].iter().any(|p| name.starts_with(p)) +} + /// A source PDF's sfnt program for an in-memory editor. Never a bundled font or an export /// fallback. Type 1/Type 3 programs cannot be consumed by an sfnt text renderer. pub fn embedded_sfnt(doc: &Document, font: &Dict) -> Option> { @@ -523,6 +536,7 @@ impl Metrics { subset: false, bold: false, italic: false, + visual_order: false, code_len: 1, explicit_reverse: (32..127u8).map(|c| (char::from(c), u32::from(c))).collect(), } @@ -560,6 +574,14 @@ impl Metrics { } } m.explicit_reverse = reverse; + // Only a font known to be laid out in visual order counts: a missing GSUB proves nothing + // (bare CFF and subset fonts often drop it), and the font program is never decoded here. + if legacy_indic_font(&m.base_font) { + let indic = |c: char| matches!(u32::from(c), 0x0900..=0x0D7F); + m.visual_order = m.unicode.explicit.values().any(|(_, t)| t.chars().any(indic)) + || m.unicode.encoding.values().any(|t| t.chars().any(indic)) + || m.unicode.ranges.iter().any(|r| r.units.iter().any(|&u| matches!(u, 0x0900..=0x0D7F))); + } m }