Edit text: support per-font visual-order Indic text and nukta reordering (#1380)

* Edit text: support per-font visual-order Indic text and nukta reordering

- Detect legacy visual-order Indic fonts via Metrics.visual_order (fonts with
  Indic codepoints without OpenType GSUB tables, or legacy visual font names).
- Implement Indic visual-to-logical and logical-to-visual pre-base vowel
  reordering in crates/edit/src/indic.rs, with support for decomposed/precomposed
  nuktas and conjuncts.
- Apply reordering during extraction and source font reuse only when visual_order
  is set, leaving modern logical-order text untouched.
- Add unit tests for nuktas, conjuncts, and round-trips in crates/edit/src/indic.rs
  and font visual_order tests in crates/edit/src/tests.rs.

* Visual-order Indic: positive evidence only, ActualText kept logical, no font decoding

- A font is visual order only when it is a known legacy Indic family (subset tag
  allowed; aps-/dv- as prefixes) with an Indic Unicode mapping. A missing GSUB is
  no evidence (bare CFF and subset fonts drop it), so Metrics::from_dict no longer
  decodes every embedded font program on every call.
- Lines with /ActualText text are already logical: skip indic::to_logical, so
  "बढ़िया" no longer becomes "बढ़यिा".
- reading_lines no longer runs bidi recovery on every line (it changed RTL
  reading_blocks output for all documents); only the Indic recovery remains.
- indic.rs indexes through get().
- Tests use a ToUnicode fixture (not visual-order ActualText), cover an edit
  round-trip, logical ActualText in a legacy font, and a subset Noto font.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-12 08:18:37 -04:00
1 parent 5cce1b247f
commit e33b66e9b5
5 files changed
+416 -7

No files matched your search

+258
View File
@@ -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<char> {
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<char> = text.chars().collect();
let mut out: Vec<char> = 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<char> = text.chars().collect();
let mut out: Vec<char> = 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);
}
}
}
+1
View File
@@ -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,
+91
View File
@@ -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 = "<FEFF092C0922093C093F092F093E>";
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> <FF> 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<String> = 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 <FEFF092C0922093C093F092F093E> >> 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<String> = 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}");
}
+44 -7
View File
@@ -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<String>,
/// 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<usize>,
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<Vec<TextLine>, 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<Vec<TextLine>, 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<Vec<TextLine>, 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<TextLine>, 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<TextLine>, 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<Vec<TextLine>, 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<Op> = 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<char, usize> = 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 {
+22
View File
@@ -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<Dict> {
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<Vec<u8>> {
@@ -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
}