mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
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:
co-authored by
Claude Opus 5.5
Brandon Thomas
parent
b655208aaf
commit
a7fe7a20c4
@@ -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. |
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user