Browse / github.com/storytold/pdfcraft / patch
feat(edit): allow custom and unicode fonts for added text (#4978)
Allow custom/Unicode fonts for added text (Greek/Cyrillic/CJK) — not just for edited text ### Summary Text added with the **Add text** tool can only use the standard-14 families (Helvetica / Times / Courier, WinAnsi encoding). Non-Latin scripts therefore can't be drawn. For example, Greek lowercase phi «φ» (U+03C6) shows the toast "The PDF text fonts can't draw 'φ' (U+03C6)…" and the text is refused/garbled in viewers. ### Steps to reproduce 1. Open any PDF, choose **Add text**, place a new text box. 2. Type Greek text containing «φ» (e.g. "γραφή"). 3. The font dropdown only offers Helvetica / Times / Courier; the character can't be rendered and a warning appears. ### Why it h
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- YusufMntm (imported from pull request #5060, open)
- Base
9e90b2a54d6250465ba3d11a6eb0b13f3b690381- Size
- +391 −50 in 9 files
- Review
- pending
- Shared
- 2026-10-28
- Id
0c18a93957896ae799299d9f
How it went
No other operator has reported using it yet.
Diff
raw · crates/edit/src/added/draw.rs crates/edit/src/added/layout.rs crates/edit/src/added/store.rs crates/edit/src/added/types.rs crates/ui-egui/src/panels/edit.rs crates/ui-egui/src/panels/format_text_ui.rs crates/ui-egui/src/workflows/files.rs crates/ui-egui/tests/it/edit.rs crates/ui-egui/tests/it/main.rs
@@ -238,11 +238,53 @@ fn type3_shaped(doc: &mut Document, clusters: &[&ShapedCluster], outlines: &Hash doc.add(Object::Dict(font)) } +/// Wraps text into lines using character advances measured from the selected font's `metrics`.+fn wrap_with_metrics(t: &AddedText, metrics: &pdfcraft_fonts::pdf::Metrics) -> Vec<String> {+ let width = (t.rect[2] - t.rect[0]).abs().max(t.size);+ let measure = |s: &str| -> f64 {+ if let Some(encoded) = metrics.encode(s) {+ let glyph_w: f64 = metrics.codes(&encoded).iter().map(|(c, _)| metrics.width(*c) * t.size).sum();+ glyph_w + t.char_spacing * s.chars().count() as f64+ } else {+ latin_width(t, s)+ }+ };+ let space = measure(" ");+ let mut out = Vec::new();+ for para in t.text.split('\n') {+ 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 line.is_empty() { measure(word) } else { line_w + space + measure(word) };+ if !line.is_empty() && candidate_w > width {+ out.push(std::mem::take(&mut line));+ line = word.to_string();+ line_w = measure(word);+ } else {+ line = candidate;+ line_w = candidate_w;+ }+ }+ out.push(line);+ }+ out+}++/// Content and resources produced by drawing an added item.+pub(super) struct DrawnItem {+ /// Content stream operations.+ pub stream: Vec<u8>,+ /// Added resources for fonts or images.+ pub resources: Dict,+ /// Adjusted bounding box if drawn with custom font metrics.+ pub custom_rect: Option<[f64; 4]>,+}+ /// 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 or /// Hebrew face keeps drawing that text as `?`, as before it was supported, rather than refusing to /// move it.-pub(super) fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict, existing: bool) -> Result<(Vec<u8>, Dict), EditError> {+pub(super) fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict, existing: bool) -> Result<DrawnItem, EditError> { let mut res = Dict::new(); let mut out = format!( "q {} {} {} {} {} {} cm\n",@@ -256,6 +298,75 @@ pub(super) fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict .into_bytes(); match c { Content::Text(t) => {+ if let Some(resource) = &t.font_resource {+ let font_obj = taken+ .get(resource.as_bytes())+ .cloned()+ .or_else(|| {+ for page in page_list(doc) {+ if let Some(f) = page+ .dict+ .get(b"Resources")+ .and_then(|r| doc.resolve(r).as_dict().cloned())+ .and_then(|r| r.get(b"Font").cloned())+ .and_then(|f| doc.resolve(&f).as_dict().cloned())+ .and_then(|f| f.get(resource.as_bytes()).cloned())+ {+ return Some(f);+ }+ }+ None+ })+ .ok_or_else(|| EditError::Invalid(format!("font resource /{resource} not found on page")))?;+ let font_dict = doc+ .resolve(&font_obj)+ .as_dict()+ .cloned()+ .ok_or_else(|| EditError::Invalid(format!("font resource /{resource} is not a dictionary")))?;+ let metrics = pdfcraft_fonts::pdf::Metrics::from_dict(doc, &font_dict);+ let mut fonts = Dict::new();+ fonts.set(resource.as_bytes().to_vec(), font_obj);+ res.set(b"Font".to_vec(), Object::Dict(fonts));+ let all = wrap_with_metrics(t, &metrics);+ let norm_r = norm(t.rect);+ let h = all.len().max(1) as f64 * t.size * 1.2;+ let r = [norm_r[0], norm_r[3] - h, norm_r[2], norm_r[3]];+ let [cr, cg, cb] = t.color.map(|v| v.clamp(0.0, 1.0));+ out.extend(format!("BT /{resource} {} Tf {} {} {} rg\n", n(t.size), n(cr), n(cg), n(cb)).bytes());+ if t.char_spacing != 0.0 {+ out.extend(format!("{} Tc\n", n(t.char_spacing)).bytes());+ }+ const HEX_DIGITS: &[u8; 16] = b"0123456789ABCDEF";+ for (i, line) in all.iter().enumerate() {+ let encoded = metrics.encode(line).ok_or_else(|| {+ let unencodable = line.chars().find(|c| metrics.encode(&c.to_string()).is_none()).unwrap_or('?');+ EditError::Invalid(format!(+ "the selected font resource {resource} cannot show '{unencodable}' (U+{:04X})",+ u32::from(unencodable)+ ))+ })?;+ let glyph_w: f64 = metrics.codes(&encoded).iter().map(|(c, _)| metrics.width(*c) * t.size).sum();+ let w = glyph_w + t.char_spacing * line.chars().count() as f64;+ let x = match t.align {+ Align::Left | Align::Justify => r[0],+ Align::Center => r[0] + ((r[2] - r[0]) - w) / 2.0,+ Align::Right => r[2] - w,+ };+ let y = r[3] - (i as f64 * 1.2 + 0.95) * t.size;+ let spaces = line.matches(' ').count();+ let tw =+ if t.align == Align::Justify && i + 1 < all.len() && spaces > 0 { ((r[2] - r[0]) - w).max(0.0) / spaces as f64 } else { 0.0 };+ out.extend(format!("1 0 0 1 {} {} Tm {} Tw <", n(x), n(y), n(tw)).bytes());+ for &b in &encoded {+ out.push(HEX_DIGITS[(b >> 4) as usize]);+ out.push(HEX_DIGITS[(b & 0x0F) as usize]);+ }+ out.extend_from_slice(b"> Tj\n");+ }+ out.extend_from_slice(b"ET\n");+ out.extend_from_slice(b"Q\n");+ return Ok(DrawnItem { stream: out, resources: res, custom_rect: Some(r) });+ } let base = t.family.base_font(t.bold, t.italic); let name = font_name(base); let mut font = Dict::new();@@ -275,7 +386,7 @@ pub(super) fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict 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");- return Ok((out, res));+ return Ok(DrawnItem { stream: out, resources: res, custom_rect: None }); } res.set(b"Font".to_vec(), Object::Dict(fonts)); let r = text_rect(t);@@ -334,9 +445,10 @@ pub(super) fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict } } out.extend_from_slice(b"Q\n");- Ok((out, res))+ Ok(DrawnItem { stream: out, resources: res, custom_rect: None }) } +/// Extracts the parameters of an added item into a `/PCAdded` dictionary. pub(super) fn params(c: &Content) -> Dict { let mut d = Dict::new(); let arr = |v: &[f64]| Object::Array(v.iter().map(|x| Object::Real(*x)).collect());@@ -345,6 +457,9 @@ pub(super) fn params(c: &Content) -> Dict { d.set(b"Kind".to_vec(), Object::name("Text")); d.set(b"Text".to_vec(), PdfString::text(&t.text)); d.set(b"Font".to_vec(), Object::name(t.family.base_font(t.bold, t.italic)));+ if let Some(font_resource) = &t.font_resource {+ d.set(b"FontResource".to_vec(), Object::name(font_resource));+ } d.set(b"Size".to_vec(), Object::Real(t.size)); d.set(b"Color".to_vec(), arr(&t.color)); d.set(@@ -120,6 +120,9 @@ pub(super) fn is_hebrew(c: char) -> bool { /// 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.font_resource.is_some() {+ return None;+ } if t.text.chars().any(is_thai) { if let Some(c) = t.text.chars().find(|c| is_thai(*c) && !pdfcraft_fonts::thai_has(*c)) { return Some(c);@@ -3,12 +3,14 @@ use super::layout::{first_undrawable, is_thai, nums, shaped_arabic, thai_error}; use super::types::{Added, AddedImage, AddedText, Align, CHAR_SPACING_LIMIT, Family, MarkerScan, TAG}; use super::*; +/// Parses an added item dictionary (`/PCAdded`) into [`Content`]. fn parse(doc: &Document, d: &Dict) -> Option<Content> { let r = nums(doc, d.get(b"Rect")); let rect: [f64; 4] = r.try_into().ok()?; match d.name(b"Kind")? { b"Text" => { let (family, bold, italic) = Family::from_base(d.name(b"Font").unwrap_or(b"Helvetica"));+ let font_resource = d.name(b"FontResource").map(|n| String::from_utf8_lossy(n).into_owned()); let c = nums(doc, d.get(b"Color")); Some(Content::Text(AddedText { rect,@@ -26,6 +28,7 @@ fn parse(doc: &Document, d: &Dict) -> Option<Content> { }, // Items written before character spacing have none. char_spacing: d.get(b"CharSpacing").and_then(Object::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0),+ font_resource, })) } b"Image" => {@@ -113,6 +116,7 @@ pub fn scan_markers(doc: &Document) -> MarkerScan { scan } +/// Validates that `c` has finite positions, sensible dimensions, and encodable text. fn validate(c: &Content) -> Result<(), EditError> { let r = c.rect(); if !r.iter().all(|v| v.is_finite()) {@@ -134,22 +138,24 @@ fn validate(c: &Content) -> Result<(), EditError> { if (r[2] - r[0]).abs() < 1.0 { return Err(EditError::Invalid("the text box is too narrow".into())); }- if t.text.chars().any(is_thai) && pdfcraft_fonts::document_thai_font().is_none() {- return Err(thai_error(GlyphError::NoFont, &t.text));- }- // Without the Arabic face, `draw` decides for the Arabic letters themselves (#403): a new- // item is refused with what's missing, and one that already holds Arabic stays movable.- // Everything else in it is drawn in the standard font, so it still has to fit.- let undrawable = if t.text.chars().any(shaped_arabic) && pdfcraft_fonts::document_arabic_font().is_none() {- t.text.split('\n').find_map(|line| {- line.chars()- .find(|c| !(shaped_arabic(*c) || is_thai(*c) && pdfcraft_fonts::thai_has(*c) || pdfcraft_fonts::win_ansi_encodable(*c)))- })- } else {- first_undrawable(t)- };- if let Some(c) = undrawable {- return Err(crate::undrawable(c));+ if t.font_resource.is_none() {+ if t.text.chars().any(is_thai) && pdfcraft_fonts::document_thai_font().is_none() {+ return Err(thai_error(GlyphError::NoFont, &t.text));+ }+ // Without the Arabic face, `draw` decides for the Arabic letters themselves (#403): a new+ // item is refused with what's missing, and one that already holds Arabic stays movable.+ // Everything else in it is drawn in the standard font, so it still has to fit.+ let undrawable = if t.text.chars().any(shaped_arabic) && pdfcraft_fonts::document_arabic_font().is_none() {+ t.text.split('\n').find_map(|line| {+ line.chars()+ .find(|c| !(shaped_arabic(*c) || is_thai(*c) && pdfcraft_fonts::thai_has(*c) || pdfcraft_fonts::win_ansi_encodable(*c)))+ })+ } else {+ first_undrawable(t)+ };+ if let Some(c) = undrawable {+ return Err(crate::undrawable(c));+ } } } Content::Image(_) => {@@ -176,11 +182,11 @@ fn write(doc: &mut Document, page: usize, c: &Content, obj: Option<ObjRef>) -> R drop_fonts(doc, &mut pres, &own_fonts(doc, r)); } let taken = pres.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();- let (content, res) = draw(doc, c, view, &taken, obj.is_some())?;+ let drawn = draw(doc, c, view, &taken, obj.is_some())?; if obj.is_none() { crate::check_on_top(doc, p)?; }- for (k, v) in res.iter() {+ for (k, v) in drawn.resources.iter() { let mut sub = pres.get(k).map(|s| doc.resolve(s)).and_then(|s| s.as_dict().cloned()).unwrap_or_default(); for (n2, o) in v.as_dict().into_iter().flat_map(|d| d.iter()) { sub.set(n2.clone(), o.clone());@@ -191,11 +197,15 @@ fn write(doc: &mut Document, page: usize, c: &Content, obj: Option<ObjRef>) -> R let mut sd = Dict::new(); sd.set(b"PCMark".to_vec(), Object::name(TAG)); let mut added = params(c);+ if let Some(rect) = drawn.custom_rect {+ let arr = |v: &[f64]| Object::Array(v.iter().map(|x| Object::Real(*x)).collect());+ added.set(b"Rect".to_vec(), arr(&rect));+ } if user_unit != 1.0 { added.set(b"UserUnit".to_vec(), Object::Real(user_unit)); } sd.set(b"PCAdded".to_vec(), Object::Dict(added));- let stream = Stream::flate(sd, &content);+ let stream = Stream::flate(sd, &drawn.stream); match obj { Some(r) => { doc.set(r, Object::Stream(stream));@@ -204,7 +214,7 @@ fn write(doc: &mut Document, page: usize, c: &Content, obj: Option<ObjRef>) -> R None => { // place_tagged appends a new stream; recover its reference. let p = page_list(doc).swap_remove(page);- place_tagged(doc, &p, TAG, content.clone(), false)?;+ place_tagged(doc, &p, TAG, drawn.stream.clone(), false)?; let p = page_list(doc).swap_remove(page); let r = contents(doc, &p)?.last().and_then(Object::as_ref).ok_or_else(|| EditError::Invalid("could not add the content".into()))?; doc.set(r, Object::Stream(stream));@@ -104,6 +104,8 @@ pub struct AddedText { pub align: Align, /// Extra space after every character, in points: the PDF's character spacing (`Tc`). pub char_spacing: f64,+ /// Explicit page font resource for custom or embedded Unicode fonts (e.g. from `RegisterTextFont`).+ pub font_resource: Option<String>, } impl Default for AddedText {@@ -118,6 +120,7 @@ impl Default for AddedText { color: [0.0; 3], align: Align::Left, char_spacing: 0.0,+ font_resource: None, } } }@@ -323,30 +323,79 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { match selected { Some((page, index, text)) => { let mut style = text.clone();- added_text_format(ui, t, &mut style);- if style != text {+ let prev_style = style.clone();+ added_text_format(app, ui, t, page, &mut style);+ if style != prev_style { // The reusable style follows the selection only when the edit took, so a refused one adds nothing odd later. if app.apply_edit(pdfcraft_engine::Edit::UpdateContent { page, index, content: pdfcraft_engine::AddedContent::Text(style.clone()) }) { app.text_style = pdfcraft_engine::AddedText { text: String::new(), rect: [0.0; 4], ..style }; } } }- None if app.quick_tool == crate::QuickTool::AddText => {- crate::content_ui::hint(ui, t);- let mut style = app.text_style.clone();- added_text_format(ui, t, &mut style);- app.text_style = style;+ None => {+ let active_draft = app.active_ids().and_then(|(i, _)| app.views[i].content.draft.as_ref().map(|d| (i, d.page, d.style.clone())));+ if let Some((view_idx, page, mut style)) = active_draft {+ let prev_draft_style = style.clone();+ added_text_format(app, ui, t, page, &mut style);+ if style != prev_draft_style {+ if let Some(draft) = app.views[view_idx].content.draft.as_mut() {+ draft.style = style.clone();+ }+ app.text_style = pdfcraft_engine::AddedText { text: String::new(), rect: [0.0; 4], ..style };+ }+ } else if app.quick_tool == crate::QuickTool::AddText {+ crate::content_ui::hint(ui, t);+ let mut style = app.text_style.clone();+ let page = app.active_ids().map(|(i, _)| app.views[i].current).unwrap_or(0);+ added_text_format(app, ui, t, page, &mut style);+ app.text_style = style;+ } }- None => {} } } -/// Format text for added text: the standard families, size, colour, style and alignment.-fn added_text_format(ui: &mut egui::Ui, t: &Tokens, style: &mut pdfcraft_engine::AddedText) {- let shown = style.family.label().to_string();- let out = crate::format_text_ui::panel(ui, t, style, None, crate::format_text_ui::Fonts { sources: None, shown });- if let Some(crate::format_text_ui::FontChoice::Family(family)) = out.font {- style.family = family;+/// Format text for added text: the standard families or loaded document fonts, size, colour, style and alignment.+fn added_text_format(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, page: usize, style: &mut pdfcraft_engine::AddedText) {+ let sources: Vec<(String, String)> = app+ .active_ids()+ .and_then(|(_, id)| app.session.get(id))+ .map(|d| d.text_editor_fonts(page))+ .unwrap_or_default()+ .into_iter()+ .map(|f| (f.resource, f.face))+ .collect();++ let shown = style+ .font_resource+ .as_ref()+ .and_then(|r| sources.iter().find(|(res, _)| res == r).map(|(_, face)| face.clone()))+ .unwrap_or_else(|| style.family.label().to_string());++ let out = crate::format_text_ui::panel(ui, t, style, None, crate::format_text_ui::Fonts { sources: Some(&sources), shown });+ match out.font {+ Some(crate::format_text_ui::FontChoice::KeepSource) => {+ style.font_resource = None;+ }+ Some(crate::format_text_ui::FontChoice::Resource(resource)) => {+ style.font_resource = Some(resource);+ }+ Some(crate::format_text_ui::FontChoice::Family(family)) => {+ style.family = family;+ style.font_resource = None;+ }+ Some(crate::format_text_ui::FontChoice::Load) => {+ app.pick_files(crate::files::FilePurpose::TextFont { page }, false);+ }+ #[cfg(not(target_arch = "wasm32"))]+ Some(crate::format_text_ui::FontChoice::Installed(path)) => {+ app.use_paths(crate::files::FilePurpose::TextFont { page }, &[path]);+ if let Some(r) = &app.text_style.font_resource {+ style.font_resource = Some(r.clone());+ }+ }+ #[cfg(target_arch = "wasm32")]+ Some(crate::format_text_ui::FontChoice::Installed(_)) => {}+ None => {} } } @@ -71,6 +71,7 @@ fn number(v: f64) -> String { s.trim_end_matches('0').trim_end_matches('.').to_string() } +/// Renders the format text panel controls for styling selected or added text. pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, look: &mut AddedText, extras: Option<&mut Extras>, fonts: Fonts) -> Outcome { let before = (look.clone(), extras.as_deref().copied()); let mut out = Outcome::default();@@ -87,14 +88,14 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, look: &mut AddedText, extras: out.font = Some(FontChoice::KeepSource); } for (resource, face) in sources.iter().filter(|(r, _)| !r.starts_with("PCApproved")) {- if ui.selectable_label(false, face).clicked() {+ if ui.selectable_label(look.font_resource.as_deref() == Some(resource), face).clicked() { out.font = Some(FontChoice::Resource(resource.clone())); } } ui.separator(); } for f in [FontFamily::Helvetica, FontFamily::Times, FontFamily::Courier] {- if ui.selectable_label(fonts.sources.is_none() && look.family == f, f.label()).clicked() {+ if ui.selectable_label(look.font_resource.is_none() && look.family == f, f.label()).clicked() { out.font = Some(FontChoice::Family(f)); } }@@ -504,21 +504,44 @@ impl PdfCraftApp { return; } let Some((index, id)) = self.active_ids() else { return };- let Some(mut ed) = self.views[index].line_editor.clone().filter(|ed| ed.page == page) else { return };- // The font just loaded is the one the user wants: apply it to the paragraph, as- // choosing it from the document-font menu does, instead of only registering it (#2591). let resource = self.session.get(id).and_then(|doc| { doc.text_editor_fonts(page).into_iter().find(|f| f.bytes == *bytes && !f.resource.starts_with("PCApproved")).map(|f| f.resource) }); let ctx = self.ctx.clone();- if resource.is_some() {- ed.font_resource = resource;- self.apply_line_format(index, ed, ctx.as_ref());- } else if let Some(doc) = self.session.get(id)- && let Some(ctx) = ctx- {- ed.fonts = crate::edit_text_ui::install_source_fonts(&ctx, doc, page);- self.views[index].line_editor = Some(ed);+ if let Some(mut ed) = self.views[index].line_editor.clone().filter(|ed| ed.page == page) {+ // The font just loaded is the one the user wants: apply it to the paragraph, as+ // choosing it from the document-font menu does, instead of only registering it (#2591).+ if resource.is_some() {+ ed.font_resource = resource;+ self.apply_line_format(index, ed, ctx.as_ref());+ } else if let Some(doc) = self.session.get(id)+ && let Some(ctx) = ctx+ {+ ed.fonts = crate::edit_text_ui::install_source_fonts(&ctx, doc, page);+ self.views[index].line_editor = Some(ed);+ }+ } else if let Some(resource) = resource {+ if let Some((sel_page, sel_idx)) = self.views[index].content.selected+ && sel_page == page+ && let Some(doc) = self.session.get(id)+ && let Some(a) = doc.added().iter().filter(|a| a.page == sel_page).nth(sel_idx)+ && let pdfcraft_engine::AddedContent::Text(text) = &a.content+ {+ let mut style = text.clone();+ style.font_resource = Some(resource.clone());+ if self.apply_edit(pdfcraft_engine::Edit::UpdateContent {+ page: sel_page,+ index: sel_idx,+ content: pdfcraft_engine::AddedContent::Text(style.clone()),+ }) {+ self.text_style = pdfcraft_engine::AddedText { text: String::new(), rect: [0.0; 4], ..style };+ }+ } else {+ self.text_style.font_resource = Some(resource.clone());+ }+ if let Some(draft) = self.views[index].content.draft.as_mut().filter(|d| d.page == page) {+ draft.style.font_resource = Some(resource);+ } } } FilePurpose::Combine => self.stage_combine(files),new file mode 100644@@ -0,0 +1,136 @@+//! Headless integration tests for adding text with custom and Unicode fonts (#4978).++use std::sync::Arc;++use egui_kittest::Harness;+use pdfcraft_engine::{AddedContent, AddedText, Edit, Session};+use pdfcraft_ui_egui::PdfCraftApp;++use crate::gpu;++/// Builds a test harness loaded with the standard single-page form fixture.+fn harness() -> Harness<'static, PdfCraftApp> {+ let bytes = include_bytes!("../data/form.pdf").to_vec();+ let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(0.25).build_eframe(move |_cc| {+ let mut app = PdfCraftApp::new();+ app.set_option("language", "en").unwrap();+ app.open_bytes("form.pdf", None, bytes.clone()).unwrap();+ app+ });+ crate::support::settle(&mut h);+ h+}++/// Verifies that text added with an explicit embedded/custom font resource can draw Greek and Unicode text (#4978).+#[test]+fn add_text_with_custom_unicode_font_succeeds_and_roundtrips() {+ let _gpu = gpu();+ let mut h = harness();+ let id = h.state().views[0].id;++ // Register Inter-Regular font on page 0.+ let font_bytes = pdfcraft_fonts::INTER_REGULAR.to_vec();+ assert!(h.state_mut().session.apply(id, Edit::RegisterTextFont { page: 0, bytes: Arc::new(font_bytes) }).is_ok());++ let doc = h.state().session.get(id).unwrap();+ let font_entry =+ doc.text_editor_fonts(0).into_iter().find(|f| !f.resource.starts_with("PCApproved")).expect("embedded document font should be listed");+ let resource = font_entry.resource.clone();++ // Greek text containing phi (U+03C6) reported in issue #4978 ("γραφή").+ let greek_text = "γραφή (Greek text with φ)";++ // Standard text without font_resource would be flagged as undrawable by first_undrawable.+ let standard_attempt = AddedText { rect: [40.0, 195.0, 240.0, 220.0], text: greek_text.to_string(), ..Default::default() };+ assert!(pdfcraft_engine::first_undrawable(&standard_attempt).is_some(), "standard font must refuse Greek phi");++ // With custom font_resource, first_undrawable returns None.+ let custom_added =+ AddedText { rect: [40.0, 195.0, 240.0, 220.0], text: greek_text.to_string(), font_resource: Some(resource.clone()), ..Default::default() };+ assert_eq!(pdfcraft_engine::first_undrawable(&custom_added), None, "custom font resource permits Greek text");++ // Apply AddText edit to the page.+ assert!(h.state_mut().apply_edit(Edit::AddText { page: 0, text: custom_added }));+ h.run_steps(3);++ // Save and reload to verify round-trip persistence of font_resource and Greek text.+ let state = h.state();+ let bytes = state.session.save_bytes(state.views[0].id).unwrap();+ let mut reopened = Session::new();+ let open_id = reopened.open("again.pdf", None, bytes, None).unwrap();+ let reopened_doc = reopened.get(open_id).unwrap();+ assert_eq!(reopened_doc.added().len(), 1);++ let AddedContent::Text(saved_text) = &reopened_doc.added()[0].content else {+ panic!("expected added text item");+ };+ assert_eq!(saved_text.text, greek_text);+ assert_eq!(saved_text.font_resource.as_deref(), Some(resource.as_str()));+}++/// Converts normalized page coordinates into screen position for page 0.+fn at(h: &Harness<'static, PdfCraftApp>, x: f32, y: f32) -> egui::Pos2 {+ let r = h.state().views[0].page_screen_rect(0).expect("on screen");+ let k = r.width() / 300.0;+ r.min + egui::vec2(x * k, y * k)+}++/// Simulates a pointer click at screen position `p`.+fn click(h: &mut Harness<'static, PdfCraftApp>, p: egui::Pos2) {+ h.hover_at(p);+ h.run_steps(1);+ h.drag_at(p);+ h.run_steps(1);+ h.drop_at(p);+ h.run_steps(3);+}++/// Verifies that typing Greek text into an active Add-text draft with custom font resource is not blocked (#4978).+#[test]+fn add_text_ui_draft_with_custom_font_is_not_blocked() {+ use egui_kittest::kittest::Queryable;+ use pdfcraft_ui_egui::QuickTool;++ let _gpu = gpu();+ let mut h = harness();+ let id = h.state().views[0].id;++ // Register Inter-Regular font on page 0.+ let font_bytes = pdfcraft_fonts::INTER_REGULAR.to_vec();+ assert!(h.state_mut().session.apply(id, Edit::RegisterTextFont { page: 0, bytes: Arc::new(font_bytes) }).is_ok());++ let doc = h.state().session.get(id).unwrap();+ let font_entry =+ doc.text_editor_fonts(0).into_iter().find(|f| !f.resource.starts_with("PCApproved")).expect("embedded document font should be listed");+ let resource = font_entry.resource.clone();++ // Set text_style font resource.+ h.state_mut().text_style.font_resource = Some(resource.clone());+ h.state_mut().quick_tool = QuickTool::AddText;+ h.run_steps(2);++ let point = at(&h, 45.0, 195.0);+ click(&mut h, point);++ let draft_style = h.state().views[0].content.draft.as_ref().expect("draft must open").style.clone();+ assert_eq!(draft_style.font_resource.as_deref(), Some(resource.as_str()));++ // Type Greek text with phi.+ h.event(egui::Event::Text("γραφή".into()));+ h.run_steps(3);++ // Blocked check must be None because custom font resource is present.+ let updated_style = h.state().views[0].content.draft.as_ref().expect("draft stays open").style.clone();+ assert_eq!(pdfcraft_engine::first_undrawable(&updated_style), None, "Greek text must not be blocked when custom font is set");++ h.get_by_label("Done adding text").click();+ h.run_steps(3);++ let added = h.state().session.get(id).unwrap().added().to_vec();+ assert_eq!(added.len(), 1);+ let AddedContent::Text(saved) = &added[0].content else {+ panic!("expected added text item");+ };+ assert_eq!(saved.text, "γραφή");+ assert_eq!(saved.font_resource.as_deref(), Some(resource.as_str()));+}@@ -39,6 +39,7 @@ mod create; mod date_format; mod dialog_chrome; mod document_links;+mod edit; mod editing; mod editor_taps; mod export;