From a7fe7a20c4f4094ff1b755227e62c2cf8ceed2b9 Mon Sep 17 00:00:00 2001 From: Michael Sharon <61873513+michaelsh1@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:45:12 +0300 Subject: [PATCH] M7: Hebrew in Add text (#128) (#2109) Add text and page_add_text draw Hebrew with the craft-fonts Hebr face (Noto Sans Hebrew) through the shaped path Arabic and Thai already use: - pdfcraft-fonts: ShapedScript::Hebrew, shape_hebrew and hebrew_has. Grapheme clusters keep points and cantillation marks on their letter. - pdfcraft-edit: Hebrew paragraphs are laid out with UAX #9 like Arabic; spaces and punctuation between Hebrew letters take the Hebrew face. first_undrawable refuses Hebrew the face can't draw (or without the face) instead of writing "?"; existing items stay movable without it. - Type 3 glyphs with ToUnicode and per-line ActualText, so extraction, search and the editor get typing order back. Regular style only, as for Arabic and Thai. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 2 + crates/automation/src/tools.rs | 2 +- crates/automation/tests/automation.rs | 27 ++++++++++ crates/edit/src/added.rs | 75 ++++++++++++++++++++------ crates/edit/src/tests.rs | 76 +++++++++++++++++++++++++++ crates/fonts/README.md | 3 ++ crates/fonts/src/lib.rs | 4 +- crates/fonts/src/shaping.rs | 55 +++++++++++++++++-- docs/localization.md | 2 +- parity/acrobat-features.toml | 4 +- 10 files changed, 225 insertions(+), 25 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index cf2a338df..1b44c4160 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -174,6 +174,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-14 (M12, transition geometry):** `pdfcraft_render::transition` gives the picture of any of the twelve page transitions at a moment of its run (which parts of the old and new page to paint, where, how opaque), in page fractions, tested for every style and direction; the full-screen presentation will paint from it once it plays transitions. - **2026-10-14 (M3, Open in Full Screen mode honoured):** a document with `/PageMode /FullScreen` now asks (Full Screen / Not now) before the app enters full screen, as Acrobat does; before, the setting was written and read but never acted on when opening. - **2026-10-14 (M3, control-click candidate):** Coordinate clicks retain hover warmup and pair press/release in one input pass so slow frames do not turn a click into a hold. Text multi-click selection accepts the actual press event when egui has already cleared its origin. Regression cases cover slow-frame button activation, disabled controls and coalesced text selection; estimates unchanged. +- **2026-10-14 (community, M7, #128 Hebrew in Add text):** Add text and `page_add_text` draw Hebrew with the craft-fonts Hebr face (Noto Sans Hebrew), through the same shaped path as Arabic and Thai: right-to-left display order with numbers and Latin in place, points and cantillation kept on their letter as one Type 3 glyph, ToUnicode and per-line ActualText so extraction, search and the editor get typing order. Builds without the face refuse Hebrew rather than write `?`. Regular style only. Estimates unchanged. - **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged. - **2026-10-13 (community, M3, #531 touchpad momentum):** On Wayland a two-finger swipe keeps gliding after the fingers lift and slows to a stop, measured from the gesture's last 0.1 s and decaying with a 0.35 s time constant through the #759 1:1 path. Any wheel input, click, key, zoom or edge ends it (fingers resting on the touchpad don't yet: the vendored winit binds no Wayland hold gestures); single-page paging, macOS (which sends its own momentum), the web, and Animation effects Off never glide. Unit tests for the physics and two headless view tests. Estimates unchanged. - **2026-10-13 (M3, native-open admission):** Later prepared replies stay queued while a modal or native picker owns the foreground. Picker completion, cancellation and abandonment wake retained opens. Regressions cover recipient ownership, exactly-once admission, source/history preservation and one-shot repaint requests. Estimates unchanged. @@ -458,6 +459,7 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| | 2026-10-13 | minor | M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. Estimates unchanged. | +| 2026-10-14 | minor | M7/#128: Hebrew in Add text with the craft-fonts Hebr face (display order, points, ToUnicode, ActualText); estimates unchanged. | | 2026-10-12 | minor | M3/#1364 candidate: absolute local GoToR links require desktop consent or headless allow_absolute; existing relative navigation and validation retained. Estimates unchanged. | | 2026-10-12 | minor | M8: redaction retires inherited `/Resources` so a parent `/Pages` can't keep a removed XObject reachable; only redacted pages that inherit are pinned (one shared reference), unredacted pages are untouched; estimates unchanged | | 2026-10-12 | minor | M7/#1200 candidate: bounded local GoToR opening, root-confined automation, and action-preserving appearance edits; named destinations and window behavior remain partial; estimates unchanged. | diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 07f955a2e..a7b30c312 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -520,7 +520,7 @@ pub fn tools() -> Vec { t( "page_add_text", "Add text to a page", - "Add text as page content (not a comment). Place it with at [x, y] (top-left, points from the top-left of the displayed page) and width (wrap width, default 200), or rect. Newlines start new lines; long lines wrap. Style: font helvetica/times/courier, size, bold, italic, color (#RRGGBB or a name), align left/center/right. Text must be Western European (WinAnsi) characters; others are refused. It stays editable with content_update. Undoable.", + "Add text as page content (not a comment). Place it with at [x, y] (top-left, points from the top-left of the displayed page) and width (wrap width, default 200), or rect. Newlines start new lines; long lines wrap. Style: font helvetica/times/courier, size, bold, italic, color (#RRGGBB or a name), align left/center/right. Text must be Western European (WinAnsi) characters, or Arabic, Hebrew or Thai when the build has their craft-fonts faces (drawn right to left where the text is); others are refused. It stays editable with content_update. Undoable.", ) .cmd("edit.text") .with(schema( diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index fa0e10034..e7600f572 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -5660,6 +5660,33 @@ fn thai_text_through_tools_saves_reopens_extracts_and_renders() { assert!(page_text(&mut a, reopened)[0].contains("แก้ไข น้ำ")); } +#[test] +fn hebrew_text_through_tools_saves_reopens_extracts_and_renders() { + let dir = workdir("hebrew-content"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path": "a.pdf"}))["doc"].as_u64().unwrap(); + let text = "שם משפחה: שרון, טל׳ 052-1234567 (נייד)"; + let args = json!({"doc": doc, "page": 1, "text": text, "rect": [10, 20, 400, 40], "size": 11, "align": "right"}); + if pdfcraft_fonts::document_hebrew_font(false).is_none() { + assert!(a.call("page_add_text", &args).unwrap_err().to_string().contains("U+05E9")); + assert_eq!(ok(&mut a, "content_list", json!({"doc": doc}))["count"], 0); + return; + } + ok(&mut a, "page_add_text", args); + // Extraction gives back the typing order, not the drawing order. + assert!(page_text(&mut a, doc)[0].contains(text), "{:?}", page_text(&mut a, doc)); + ok(&mut a, "doc_save", json!({"doc": doc, "path": "hebrew.pdf"})); + let reopened = ok(&mut a, "doc_open", json!({"path": "hebrew.pdf"}))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "content_list", json!({"doc": reopened}))["items"][0]["text"], text); + assert!(page_text(&mut a, reopened)[0].contains(text)); + let found = ok(&mut a, "text_find", json!({"doc": reopened, "query": "שרון"})); + assert_eq!(found["matches"].as_array().map(Vec::len), Some(1), "{found}"); + let rendered = a.call("page_render", &json!({"doc": reopened, "page": 1, "dpi": 144})).unwrap(); + assert!(rendered.iter().any(|c| matches!(c, Content::Png { data, width, height } if !data.is_empty() && *width > 0 && *height > 0))); + ok(&mut a, "content_update", json!({"doc": reopened, "page": 1, "index": 1, "text": "מיכאל שָׁלוֹם"})); + assert!(page_text(&mut a, reopened)[0].contains("מיכאל")); +} + #[test] fn mixed_objects_move_through_tools_with_generation_and_one_undo() { let dir = workdir("mixed-object-move"); diff --git a/crates/edit/src/added.rs b/crates/edit/src/added.rs index 12008dee8..2b6c9d13a 100644 --- a/crates/edit/src/added.rs +++ b/crates/edit/src/added.rs @@ -11,7 +11,9 @@ use std::collections::{HashMap, HashSet}; use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; -use pdfcraft_fonts::{GlyphError, GlyphOutline, ShapedCluster, ShapedScript, Std14, literal, shape_arabic, shape_thai, shaped_glyph, win_ansi}; +use pdfcraft_fonts::{ + GlyphError, GlyphOutline, ShapedCluster, ShapedScript, Std14, literal, shape_arabic, shape_hebrew, shape_thai, shaped_glyph, win_ansi, +}; use unicode_bidi::{Level, ParagraphBidiInfo}; use crate::{EditError, MarkKind, check, contents, matrix_number, n, page_list, place_tagged}; @@ -239,27 +241,28 @@ fn wrapped(t: &AddedText) -> Vec<(String, bool)> { let width = (t.rect[2] - t.rect[0]).max(t.size); let mut out = Vec::new(); for para in t.text.split('\n') { - let arabic = para.chars().any(is_arabic); + // Arabic or Hebrew: laid out with the bidirectional algorithm and shaped. + let rtl_script = para.chars().any(|c| is_arabic(c) || is_hebrew(c)); let thai = para.chars().any(is_thai); if thai - && !arabic + && !rtl_script && let Ok(lines) = thai_lines(t, para, width) { out.extend(lines.into_iter().map(|line| (line, false))); continue; } - let rtl = arabic && unicode_bidi::get_base_direction(para) == unicode_bidi::Direction::Rtl; + let rtl = rtl_script && unicode_bidi::get_base_direction(para) == unicode_bidi::Direction::Rtl; // Arabic words are shaped one by one (joining stops at spaces), so a line's width is the // sum of its words' and shaping stays linear in the paragraph's length. The space is the // paragraph's; a space between Latin words is drawn in the item's font, a few hundredths // of an em apart. - let measure = |s: &str| if arabic || thai { shaped_width(t, s, rtl) } else { t.family.width(s, t.size, t.bold, t.italic) }; + let measure = |s: &str| if rtl_script || thai { shaped_width(t, s, rtl) } else { t.family.width(s, t.size, t.bold, t.italic) }; let space = measure(" "); let (mut line, mut line_w) = (String::new(), 0.0); for word in para.split(' ') { let candidate = if line.is_empty() { word.to_string() } else { format!("{line} {word}") }; let candidate_w = - if arabic || thai { if line.is_empty() { measure(word) } else { line_w + space + measure(word) } } else { measure(&candidate) }; + if rtl_script || thai { if line.is_empty() { measure(word) } else { line_w + space + measure(word) } } else { measure(&candidate) }; if !line.is_empty() && candidate_w > width { out.push((std::mem::take(&mut line), rtl)); line = word.to_string(); @@ -313,9 +316,16 @@ fn is_arabic(c: char) -> bool { matches!(u32::from(c), 0x0600..=0x06FF | 0x0750..=0x077F | 0x0870..=0x08FF | 0xFB50..=0xFDFF | 0xFE70..=0xFEFC) } +/// Hebrew letters, points, cantillation marks and punctuation (and the presentation forms): an +/// item with any of them is drawn with the craft-fonts Hebrew face. +fn is_hebrew(c: char) -> bool { + matches!(u32::from(c), 0x0591..=0x05F4 | 0xFB1D..=0xFB4F) +} + /// The first character of `t` that would be drawn as `?`: outside WinAnsiEncoding and not drawn -/// with the craft-fonts Arabic face (#125), or Arabic that the face can't draw (or that has no -/// face to draw it). Page-content tools refuse such text; the Add-text editor keeps it open. +/// with the craft-fonts Arabic (#125) or Hebrew (#128) face, or Arabic or Hebrew that the face can't +/// draw (or that has no face to draw it). Page-content tools refuse such text; the Add-text editor +/// keeps it open. pub fn first_undrawable(t: &AddedText) -> Option { if t.text.chars().any(is_thai) { if let Some(c) = t.text.chars().find(|c| is_thai(*c) && !pdfcraft_fonts::thai_has(*c)) { @@ -324,11 +334,13 @@ pub fn first_undrawable(t: &AddedText) -> Option { let rest = AddedText { text: t.text.chars().filter(|c| !is_thai(*c)).collect(), ..t.clone() }; return first_undrawable(&rest); } - // Arabic that nothing can draw: there's no face, or the face lacks it. - if let Some(c) = t.text.chars().find(|c| shaped_arabic(*c) && !pdfcraft_fonts::arabic_has(*c)) { + // Arabic or Hebrew that nothing can draw: there's no face, or the face lacks it. + if let Some(c) = + t.text.chars().find(|c| (shaped_arabic(*c) && !pdfcraft_fonts::arabic_has(*c)) || (is_hebrew(*c) && !pdfcraft_fonts::hebrew_has(*c))) + { return Some(c); } - if pdfcraft_fonts::document_arabic_font().is_none() || !t.text.chars().any(is_arabic) { + if !shaped_by_a_face(t) { // Drawn line by line in the item's standard font (`\n` splits lines). Without the face // that is also how `draw` redraws an existing item, so it's checked that way. return t.text.split('\n').find_map(pdfcraft_fonts::first_non_win_ansi); @@ -343,6 +355,13 @@ pub fn first_undrawable(t: &AddedText) -> Option { }) } +/// Whether `t` has Arabic or Hebrew that its craft-fonts face draws (so `draw` lays it out with +/// [`draw_shaped`]). +fn shaped_by_a_face(t: &AddedText) -> bool { + (pdfcraft_fonts::document_arabic_font().is_some() && t.text.chars().any(is_arabic)) + || (pdfcraft_fonts::document_hebrew_font(false).is_some() && t.text.chars().any(is_hebrew)) +} + /// An Arabic character the face shapes and draws (U+061C, a direction mark, is dropped). fn shaped_arabic(c: char) -> bool { is_arabic(c) && !is_bidi_control(c) @@ -391,12 +410,16 @@ fn shaped_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec, f6 let run_rtl = levels.get(run.start).is_some_and(Level::is_rtl); // Runs lie on character boundaries; a missing slice is skipped, never a panic. let Some(text) = line.get(run) else { continue }; - // Arabic letters take the Arabic face; so do spaces and punctuation between them, which the - // shaper then mirrors. Latin text (and what the face lacks) keeps the item's font. + // Arabic and Hebrew letters take their face; so do spaces and punctuation between them + // (Hebrew's when they follow Hebrew), which the shaper then mirrors. Latin text (and what + // the faces lack) keeps the item's font. let mut segments: Vec<(Option, String)> = Vec::new(); for c in text.chars().filter(|c| !is_bidi_control(*c)) { + let after_hebrew = segments.last().is_some_and(|(kind, _)| *kind == Some(ShapedScript::Hebrew)); let script = if is_thai(c) { Some(ShapedScript::Thai) + } else if is_hebrew(c) || (run_rtl && after_hebrew && !c.is_alphanumeric() && pdfcraft_fonts::hebrew_has(c)) { + Some(ShapedScript::Hebrew) } else if is_arabic(c) || (run_rtl && !c.is_alphanumeric() && pdfcraft_fonts::arabic_has(c)) { Some(ShapedScript::Arabic) } else { @@ -414,6 +437,7 @@ fn shaped_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec, f6 pieces.push(if let Some(script) = script { let shaped = match script { ShapedScript::Arabic => shape_arabic(&s, run_rtl).map_err(|e| arabic_error(e, &s))?, + ShapedScript::Hebrew => shape_hebrew(&s, run_rtl).map_err(|e| hebrew_error(e, &s))?, ShapedScript::Thai => shape_thai(&s).map_err(|e| thai_error(e, &s))?, }; Piece::Shaped(shaped) @@ -450,6 +474,16 @@ fn arabic_error(e: GlyphError, text: &str) -> EditError { }) } +fn hebrew_error(e: GlyphError, text: &str) -> EditError { + EditError::Invalid(match e { + GlyphError::NoFont => "Hebrew text needs PdfCraft's Hebrew font, which this build doesn't include \ + (to build it in, set CRAFT_FONTS_DIR to a craft-fonts checkout that has a Hebr face)" + .into(), + GlyphError::Missing => format!("the Hebrew font can't show \"{text}\""), + GlyphError::TooComplex => format!("the Hebrew font's glyphs for \"{text}\" are too complex"), + }) +} + fn thai_error(e: GlyphError, text: &str) -> EditError { EditError::Invalid(match e { GlyphError::NoFont => { @@ -479,7 +513,7 @@ const GLYPHS_PER_FONT: usize = 240; /// its pieces in drawing order and its width at the item's size. type LaidLine = (String, bool, Vec, f64); -/// Draw a text item that has Arabic or Thai in it. Each line is laid out in display order; Arabic glyphs +/// Draw a text item that has Arabic, Hebrew or Thai in it. Each line is laid out in display order; their glyphs /// come from Type 3 fonts drawn from the craft-fonts face's outlines (no font file is embedded), /// with a ToUnicode map for copy and search. Other text uses the item's standard font. fn draw_shaped(doc: &mut Document, t: &AddedText, taken: &Dict, fonts: &mut Dict, out: &mut Vec) -> Result<(), EditError> { @@ -524,6 +558,7 @@ fn draw_laid_shaped( if let std::collections::hash_map::Entry::Vacant(entry) = outlines.entry((c.script, *id)) { entry.insert(outline(c.script, *id).map_err(|e| match c.script { ShapedScript::Arabic => arabic_error(e, &c.text), + ShapedScript::Hebrew => hebrew_error(e, &c.text), ShapedScript::Thai => thai_error(e, &c.text), })?); } @@ -663,6 +698,7 @@ fn type3_shaped(doc: &mut Document, clusters: &[&ShapedCluster], outlines: &Hash Some(script) => script.font().map_or( match script { ShapedScript::Arabic => "Arabic", + ShapedScript::Hebrew => "Hebrew", ShapedScript::Thai => "Thai", }, |f| f.family, @@ -697,8 +733,9 @@ fn type3_shaped(doc: &mut Document, clusters: &[&ShapedCluster], outlines: &Hash } /// Content and resources for an item; `view` maps display space to user space. `taken` holds the -/// page's font names. `existing`: the item is being replaced, so a build without the Arabic face -/// keeps drawing its Arabic as `?`, as before Arabic was supported, rather than refusing to move it. +/// page's font names. `existing`: the item is being replaced, so a build without the Arabic or +/// Hebrew face keeps drawing that text as `?`, as before it was supported, rather than refusing to +/// move it. fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict, existing: bool) -> Result<(Vec, Dict), EditError> { let mut res = Dict::new(); let mut out = format!( @@ -724,7 +761,11 @@ fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict, existing: } let mut fonts = Dict::new(); fonts.set(name.clone().into_bytes(), Object::Dict(font)); - if t.text.chars().any(is_thai) || (t.text.chars().any(is_arabic) && (pdfcraft_fonts::document_arabic_font().is_some() || !existing)) { + let face_or_new = |has: fn(char) -> bool, face: bool| t.text.chars().any(has) && (face || !existing); + if t.text.chars().any(is_thai) + || face_or_new(is_arabic, pdfcraft_fonts::document_arabic_font().is_some()) + || face_or_new(is_hebrew, pdfcraft_fonts::document_hebrew_font(false).is_some()) + { draw_shaped(doc, t, taken, &mut fonts, &mut out)?; res.set(b"Font".to_vec(), Object::Dict(fonts)); out.extend_from_slice(b"Q\n"); diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 0612b757d..b78525ad7 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -693,6 +693,82 @@ fn added_arabic_text_is_shaped_in_display_order_and_stays_searchable() { } } +#[test] +fn added_hebrew_text_is_drawn_right_to_left_and_stays_searchable() { + let mut doc = fixture(); + let text = AddedText { + rect: [72.0, 600.0, 400.0, 700.0], + text: "שלום (עולם) 2026 World".into(), + size: 14.0, + align: Align::Right, + ..AddedText::default() + }; + if pdfcraft_fonts::document_hebrew_font(false).is_none() { + eprintln!("skipping the Hebrew drawing checks: built without a craft-fonts Hebr face (set CRAFT_FONTS_DIR)"); + assert_eq!(first_undrawable(&text), Some('ש')); + let err = add_content(&mut doc, 0, &Content::Text(text)).unwrap_err(); + assert!(matches!(&err, EditError::Invalid(m) if m.contains("U+05E9")), "{err:?}"); + assert!(!doc.is_modified(), "nothing is written when the font is missing"); + return; + } + assert_eq!(first_undrawable(&text), None); + add_content(&mut doc, 0, &Content::Text(text.clone())).unwrap(); + // Points stay on their letters; a Hebrew line in an Arabic item and Arabic in a Hebrew one. + for other in ["שָׁלוֹם", "שלום مرحبا"] { + let other = AddedText { text: other.into(), rect: [72.0, 500.0, 400.0, 520.0], ..text.clone() }; + if other.text.chars().any(|c| ('\u{0600}'..='\u{06FF}').contains(&c)) && pdfcraft_fonts::document_arabic_font().is_none() { + continue; + } + add_content(&mut doc, 0, &Content::Text(other)).unwrap(); + } + let doc = reopen(&doc); + let added = list_added(&doc); + let Content::Text(t) = &added[0].content else { panic!() }; + assert_eq!(t.text, "שלום (עולם) 2026 World", "the logical text is kept for editing"); + let s = streams(&doc, 0); + let item = s.iter().find(|s| s.contains("(World) Tj")).unwrap(); + // A right-to-left paragraph: "World", typed last, is drawn left of the Hebrew. + let x_before = |s: &str, at: usize| -> f64 { + let tm = s[..at].rfind("1 0 0 1 ").unwrap() + 8; + s[tm..].split(' ').next().unwrap().parse().unwrap() + }; + let latin_x = x_before(item, item.find("(World) Tj").unwrap()); + let hebrew_at = item.find("/PCAr").unwrap(); + let hebrew_x = x_before(item, item[hebrew_at..].find(" Tj").unwrap() + hebrew_at); + assert!(latin_x < hebrew_x, "{item}"); + // Copy and search get the typing order back. + let logical: String = t.text.encode_utf16().map(|u| format!("{u:04X}")).collect(); + assert!(item.contains(&format!("/ActualText ")), "{item}"); + // The Hebrew glyphs come from a Type 3 font named after the face, whose ToUnicode map gives + // back each letter. + let p = &pdfcraft_model::pages(&doc)[0]; + let res = doc.resolve(p.dict.get(b"Resources").unwrap()); + let fonts = doc.resolve(res.as_dict().unwrap().get(b"Font").unwrap()).as_dict().unwrap().clone(); + let shaped: Vec<_> = fonts.iter().filter(|(k, _)| k.starts_with(b"PCAr")).map(|(_, v)| doc.resolve(v).as_dict().unwrap().clone()).collect(); + let mut mapped = String::new(); + for f in &shaped { + assert_eq!(f.name(b"Subtype"), Some(&b"Type3"[..])); + mapped += &String::from_utf8_lossy(&stream_bytes(&doc, f.get(b"ToUnicode").unwrap()).unwrap()); + } + for letter in ["05E9", "05DC", "05D5", "05DD", "05E2"] { + assert!(mapped.contains(&format!("<{letter}>")), "{letter} in {mapped}"); + } + let font_name = |f: &Dict| doc.resolve(f.get(b"FontDescriptor").unwrap()).as_dict().unwrap().name(b"FontName").map(<[u8]>::to_vec); + assert!(shaped.iter().any(|f| font_name(f).as_deref() == Some(&b"NotoSansHebrew"[..]))); + // A pointed letter is one glyph with its points, mapped back whole. + assert!(mapped.contains("<05E905"), "{mapped}"); +} + +#[test] +fn odd_hebrew_text_never_panics() { + let mut doc = fixture(); + let long = "שלום עולם ".repeat(2_000); + for s in ["\u{202E}ש\u{05B8}\u{05B8} (]", "\u{05B8}", "א\u{200F}\u{2067}b\u{2069}", "א\n\nב\tc", "׳״ 456", long.as_str()] { + let r = add_content(&mut doc, 0, &Content::Text(AddedText { rect: [0.0, 800.0, 300.0, 780.0], text: s.into(), ..AddedText::default() })); + assert_eq!(r.is_ok(), pdfcraft_fonts::document_hebrew_font(false).is_some(), "{:?}: {r:?}", s.chars().take(12).collect::()); + } +} + /// The `PCAr` font names in page `page`'s resources. fn arabic_font_names(doc: &Document, page: usize) -> Vec { let p = &pdfcraft_model::pages(doc)[page]; diff --git a/crates/fonts/README.md b/crates/fonts/README.md index e7e3ef117..36b305bd9 100644 --- a/crates/fonts/README.md +++ b/crates/fonts/README.md @@ -37,6 +37,9 @@ set). Without that variable `CRAFT_FONTS` is empty and everything below copes: Hebrew interface text, and the face whose outlines become the Type 3 fallback for Hebrew text written by Edit text (Bold when asked for and present, else Regular). `None` without a craft-fonts input that has one; the editor then reports a clear error. +- `shape_hebrew` / `hebrew_has`: the Regular `Hebr` face shaped with harfrust for Hebrew Add + Text content, one cluster per letter with its points and cantillation marks (positioned by the + face), drawn by the same Type 3 writer as Arabic and Thai. `GlyphError::NoFont` without the face. - `document_japanese_font` / `japanese_glyph`: the face (Shippori Mincho, then BIZ UDMincho) whose outlines become the Type 3 fallback font for Japanese text written into PDFs. Without it, `japanese_glyph` returns `GlyphError::NoFont` and the editor reports a clear error. diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index a0573b1e3..d1c010769 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -25,7 +25,9 @@ pub use craft::{ pub use script::{ GlyphError, GlyphOutline, INTER_REGULAR, MAX_SIGNATURE_CHARS, ScriptOutline, japanese_glyph, japanese_glyph_from, script_missing, script_outline, }; -pub use shaping::{ShapedCluster, ShapedScript, arabic_glyph, arabic_has, shape_arabic, shape_thai, shaped_glyph, thai_has}; +pub use shaping::{ + ShapedCluster, ShapedScript, arabic_glyph, arabic_has, hebrew_has, shape_arabic, shape_hebrew, shape_thai, shaped_glyph, thai_has, +}; pub use std14::Std14; /// Advance of `s` in Helvetica at `size` points, using the exact standard-14 metrics. diff --git a/crates/fonts/src/shaping.rs b/crates/fonts/src/shaping.rs index 75b6321cf..f0bd118c1 100644 --- a/crates/fonts/src/shaping.rs +++ b/crates/fonts/src/shaping.rs @@ -1,4 +1,4 @@ -//! Arabic and Thai text shaping with the matching craft-fonts face and bounded glyph outlines. +//! Arabic, Hebrew and Thai text shaping with the matching craft-fonts face and bounded glyph outlines. use std::sync::OnceLock; @@ -26,6 +26,7 @@ pub struct ShapedCluster { #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum ShapedScript { Arabic, + Hebrew, Thai, } @@ -33,6 +34,7 @@ impl ShapedScript { pub fn font(self) -> Option<&'static crate::CraftFont> { match self { Self::Arabic => crate::document_arabic_font(), + Self::Hebrew => crate::document_hebrew_font(false), Self::Thai => crate::document_thai_font(), } } @@ -40,9 +42,11 @@ impl ShapedScript { fn face(script: ShapedScript) -> Option<&'static (FontRef<'static>, ShaperData)> { static FACE: OnceLock, ShaperData)>> = OnceLock::new(); + static HEBREW: OnceLock, ShaperData)>> = OnceLock::new(); static THAI: OnceLock, ShaperData)>> = OnceLock::new(); let cache = match script { ShapedScript::Arabic => &FACE, + ShapedScript::Hebrew => &HEBREW, ShapedScript::Thai => &THAI, }; cache @@ -66,6 +70,17 @@ pub fn shape_arabic(text: &str, rtl: bool) -> Result, GlyphEr shape_script(text, rtl, ShapedScript::Arabic) } +/// Whether the Hebrew document face has a glyph for `c` (false without the face). +pub fn hebrew_has(c: char) -> bool { + face(ShapedScript::Hebrew).is_some_and(|(font, _)| font.charmap().map(c).is_some()) +} + +/// Shape one directional run of `text` with the Hebrew document face, clusters in drawing order. +/// Points (niqqud) and cantillation marks stay in their letter's cluster, positioned by the face. +pub fn shape_hebrew(text: &str, rtl: bool) -> Result, GlyphError> { + shape_script(text, rtl, ShapedScript::Hebrew) +} + pub fn thai_has(c: char) -> bool { face(ShapedScript::Thai).is_some_and(|(font, _)| font.charmap().map(c).is_some()) } @@ -85,12 +100,13 @@ fn shape_script(text: &str, rtl: bool, kind: ShapedScript) -> Result script::ARABIC, + ShapedScript::Hebrew => script::HEBREW, ShapedScript::Thai => script::THAI, }); - // Preserve the historical Arabic per-character mapping; Thai marks stay with their base. + // Preserve the historical Arabic per-character mapping; Hebrew and Thai marks stay with their base. buffer.set_cluster_level(match kind { ShapedScript::Arabic => BufferClusterLevel::Characters, - ShapedScript::Thai => BufferClusterLevel::MonotoneGraphemes, + ShapedScript::Hebrew | ShapedScript::Thai => BufferClusterLevel::MonotoneGraphemes, }); let shaped = shaper.shape(buffer, ShapeOptions::new()); // Clusters are byte offsets into `text`; a cluster shows the characters up to the next one. @@ -250,3 +266,36 @@ mod thai_tests { assert_eq!(shaped_glyph(ShapedScript::Thai, u32::MAX).map(|_| ()), Err(GlyphError::Missing)); } } + +#[cfg(test)] +mod hebrew_tests { + use super::*; + + #[test] + fn hebrew_runs_are_drawn_from_their_last_letter_with_points_on_their_letter() { + if crate::document_hebrew_font(false).is_none() { + eprintln!("skipping the Hebrew shaping checks: built without a craft-fonts Hebr face (set CRAFT_FONTS_DIR)"); + assert_eq!(shape_hebrew("ש", true), Err(GlyphError::NoFont)); + assert!(!hebrew_has('ש')); + return; + } + let g = shape_hebrew("שלום", true).unwrap(); + assert!(g.iter().all(|c| c.script == ShapedScript::Hebrew && c.advance > 0.0 && !c.glyphs.is_empty()), "{g:?}"); + let drawn: String = g.iter().map(|c| c.text.as_str()).collect(); + assert_eq!(drawn.chars().rev().collect::(), "שלום"); + // A point (qamats) is one cluster with its letter, so copy and search get it once. + let pointed = shape_hebrew("שָׁ", true).unwrap(); + assert_eq!(pointed.len(), 1, "{pointed:?}"); + assert_eq!(pointed[0].text, "שָׁ"); + assert!(pointed[0].glyphs.len() >= 2); + for c in shape_hebrew("שָׁלוֹם 123", true).unwrap() { + for (id, pos) in c.glyphs { + assert!(pos.iter().all(|v| v.is_finite())); + shaped_glyph(c.script, id).unwrap(); + } + } + assert!(hebrew_has('א') && hebrew_has('\u{05B8}') && !hebrew_has('日')); + assert_eq!(shape_hebrew("日", true), Err(GlyphError::Missing)); + assert!(shape_hebrew("", true).unwrap().is_empty()); + } +} diff --git a/docs/localization.md b/docs/localization.md index 89ef2d74d..c843c1a16 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -24,7 +24,7 @@ The Lithuanian catalog has 2,585 entries and three integer plural forms. Calenda The Vietnamese catalog has 2,591 entries and one plural form (`plural_none`). Preferences theme `Appearance` uses context `theme` → `Giao diện`; object/comment/signature appearance stays plain `Diện mạo`. Form fields use `Trường`; redaction apply wording states permanent removal. Calendar months are numeric (`Tháng 1`…`12` / `tháng 1`…`12`). Coverage tests require every current command, UI literal, bundled key and credit label. A native Vietnamese-speaking contributor has reviewed the translation. Status stays `partial`: no translated help, and engine/OS details stay English inside a translated frame. -Edit text reads Hebrew lines in typing order and writes them back in drawing order (`crates/edit/src/bidi.rs`); letters the page's own font lacks are drawn with the craft-fonts `Hebr` face (Noto Sans Hebrew) as Type 3 glyphs, and a build without one says so. Hebrew points (niqqud) can't be written yet. The craft-fonts revision pinned for CI and releases has no `Hebr` face yet, so until one is added releases report that error instead of drawing the missing letters. +Edit text reads Hebrew lines in typing order and writes them back in drawing order (`crates/edit/src/bidi.rs`); letters the page's own font lacks are drawn with the craft-fonts `Hebr` face (Noto Sans Hebrew) as Type 3 glyphs, and a build without one says so. Hebrew points (niqqud) can't be written yet in Edit text. Add text (#128) draws Hebrew with the same face through the shaped path Arabic and Thai use: right to left with numbers and Latin in place, points kept on their letter, and typing order for copy and search. The craft-fonts revision pinned for CI and releases has no `Hebr` face yet, so until one is added releases report that error instead of drawing the missing letters, and refuse Hebrew in Add text. Japanese interface text uses BIZ UDPGothic from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input that every release includes (`CRAFT_FONTS_DIR`; see the README). A build made without it has no Japanese face, so Japanese labels show replacement boxes. Chinese is drawn with the same craft-fonts CJK faces (in Simplified Chinese the `Hans` faces come first, so a line never mixes faces with different baselines), so a character none of them has shows a replacement box (and every Chinese character does in a build without craft-fonts). Russian, Bulgarian, Czech, Brazilian Portuguese, German, Spanish, French, Hungarian, Italian, Turkish and Lithuanian need only the bundled Latin faces (tested in `tests/fonts.rs`). Hungarian uses the same one/other plural rule as English: 1 is singular, and every other count — including 0 — is the other form. After a numeral the noun stays singular (`1 oldal`, `2 oldal`). Telugu is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, kept in the web build too), whose conjuncts egui shapes; a build without one shows Telugu labels as replacement boxes, and the release-pinned craft-fonts input has no Telugu face yet. Japanese interface text uses BIZ UDPGothic from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input that every release includes (`CRAFT_FONTS_DIR`; see the README). A build made without it has no Japanese face, so Japanese labels show replacement boxes. Chinese is drawn with the same craft-fonts CJK faces (in Simplified Chinese the `Hans` faces come first, so a line never mixes faces with different baselines), so a character none of them has shows a replacement box (and every Chinese character does in a build without craft-fonts). Russian, Bulgarian, Czech, Brazilian Portuguese, German, Spanish, French, Hungarian, Italian and Slovenian need only the bundled Latin faces (tested in `tests/fonts.rs`). Slovenian has four plural forms: one (1, 101, 201…), two (2, 102…), few (3, 4, 103…) and other (everything else, including 0, 5–20 and 21+), so `@plural` entries list four forms (`1 stran`, `2 strani`, `3 strani`, `5 strani`). Hungarian uses the same one/other plural rule as English: 1 is singular, and every other count — including 0 — is the other form. After a numeral the noun stays singular (`1 oldal`, `2 oldal`). Telugu is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, kept in the web build too), whose conjuncts egui shapes; a build without one shows Telugu labels as replacement boxes, and the release-pinned craft-fonts input has no Telugu face yet. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 1958efb9d..9942adeb6 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2010,8 +2010,8 @@ milestone = "M7" status = "shipped" commands = ["edit.text"] tools = ["page_add_text", "content_update"] -evidence = ["crates/ui-egui/tests/content.rs::typing_moving_styling_and_deleting_added_content", "crates/ui-egui/tests/content.rs::clicking_elsewhere_keeps_the_text_and_starts_a_new_box", "crates/automation/tests/automation.rs::adding_content_through_tools", "crates/edit/src/tests.rs::added_text_and_images_are_page_content_that_stays_editable", "crates/engine/src/tests.rs::added_text_and_images_render_and_stay_editable", "crates/edit/src/tests.rs::added_arabic_text_is_shaped_in_display_order_and_stays_searchable", "crates/automation/tests/automation.rs::thai_text_through_tools_saves_reopens_extracts_and_renders", "crates/ui-egui/tests/content.rs::typing_thai_text_keeps_marks_and_commits_to_page", "crates/automation/tests/automation.rs::added_arabic_text_keeps_copy_search_and_editor_order_after_retyping_and_reopen"] -notes = "page content, editable later (retype, move, restyle); standard fonts, Windows-1252 text. Arabic text is shaped with the craft-fonts Arab face (Type 3 outlines with ToUnicode) and laid out in bidirectional display order; per-line ActualText keeps copy, search and editor text in typing order through retyping and save/reopen; it is drawn regular whatever the bold/italic setting, Hebrew and other right-to-left scripts are not covered, and typing in the box is still left to right (misc.rtl-ui). Thai Add Text uses the craft-fonts Thai face, with shaped clusters and ToUnicode; Thai is regular style, wrapping preserves clusters but is not dictionary-based, and forms/Fill and Sign are not covered by this change. Each click on the page keeps the box being typed and starts a new one; Done and Discard beside the box" +evidence = ["crates/ui-egui/tests/content.rs::typing_moving_styling_and_deleting_added_content", "crates/ui-egui/tests/content.rs::clicking_elsewhere_keeps_the_text_and_starts_a_new_box", "crates/automation/tests/automation.rs::adding_content_through_tools", "crates/edit/src/tests.rs::added_text_and_images_are_page_content_that_stays_editable", "crates/engine/src/tests.rs::added_text_and_images_render_and_stay_editable", "crates/edit/src/tests.rs::added_arabic_text_is_shaped_in_display_order_and_stays_searchable", "crates/automation/tests/automation.rs::thai_text_through_tools_saves_reopens_extracts_and_renders", "crates/ui-egui/tests/content.rs::typing_thai_text_keeps_marks_and_commits_to_page", "crates/automation/tests/automation.rs::added_arabic_text_keeps_copy_search_and_editor_order_after_retyping_and_reopen", "crates/edit/src/tests.rs::added_hebrew_text_is_drawn_right_to_left_and_stays_searchable", "crates/automation/tests/automation.rs::hebrew_text_through_tools_saves_reopens_extracts_and_renders"] +notes = "page content, editable later (retype, move, restyle); standard fonts, Windows-1252 text. Arabic text is shaped with the craft-fonts Arab face (Type 3 outlines with ToUnicode) and laid out in bidirectional display order; per-line ActualText keeps copy, search and editor text in typing order through retyping and save/reopen; it is drawn regular whatever the bold/italic setting, and typing in the box is still left to right (misc.rtl-ui). Hebrew (#128) takes the same path with the craft-fonts Hebr face (Noto Sans Hebrew): right-to-left display order, points and cantillation kept on their letter as one glyph, ToUnicode and per-line ActualText; regular style only, and right-to-left scripts other than Arabic and Hebrew are not covered. Thai Add Text uses the craft-fonts Thai face, with shaped clusters and ToUnicode; Thai is regular style, wrapping preserves clusters but is not dictionary-based, and forms/Fill and Sign are not covered by this change. Each click on the page keeps the box being typed and starts a new one; Done and Discard beside the box" [[feature]] id = "edit.find-replace"