mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
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:
5 files changed
+416
-7
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user