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 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Michael Sharon
2026-10-15 02:45:12 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Brandon Thomas
parent b655208aaf
commit a7fe7a20c4
10 changed files with 225 additions and 25 deletions
+2
View File
@@ -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. |
+1 -1
View File
@@ -520,7 +520,7 @@ pub fn tools() -> Vec<ToolDef> {
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(
+27
View File
@@ -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");
+58 -17
View File
@@ -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<char> {
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<char> {
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<char> {
})
}
/// 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<Piece>, 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<ShapedScript>, 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<Piece>, 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<Piece>, 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<u8>) -> 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<u8>, 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");
+76
View File
@@ -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 <FEFF{logical}>")), "{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::<String>());
}
}
/// The `PCAr` font names in page `page`'s resources.
fn arabic_font_names(doc: &Document, page: usize) -> Vec<String> {
let p = &pdfcraft_model::pages(doc)[page];
+3
View File
@@ -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.
+3 -1
View File
@@ -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.
+52 -3
View File
@@ -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<Option<(FontRef<'static>, ShaperData)>> = OnceLock::new();
static HEBREW: OnceLock<Option<(FontRef<'static>, ShaperData)>> = OnceLock::new();
static THAI: OnceLock<Option<(FontRef<'static>, 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<Vec<ShapedCluster>, 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<Vec<ShapedCluster>, 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<Vec<ShapedC
buffer.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight });
buffer.set_script(match kind {
ShapedScript::Arabic => 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::<String>(), "שלום");
// 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());
}
}
+1 -1
View File
@@ -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.
+2 -2
View File
@@ -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"