collective

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

diff --git a/crates/edit/src/added/draw.rs b/crates/edit/src/added/draw.rs--- a/crates/edit/src/added/draw.rs+++ b/crates/edit/src/added/draw.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(diff --git a/crates/edit/src/added/layout.rs b/crates/edit/src/added/layout.rs--- a/crates/edit/src/added/layout.rs+++ b/crates/edit/src/added/layout.rs@@ -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);diff --git a/crates/edit/src/added/store.rs b/crates/edit/src/added/store.rs--- a/crates/edit/src/added/store.rs+++ b/crates/edit/src/added/store.rs@@ -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));diff --git a/crates/edit/src/added/types.rs b/crates/edit/src/added/types.rs--- a/crates/edit/src/added/types.rs+++ b/crates/edit/src/added/types.rs@@ -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,         }     } }diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs--- a/crates/ui-egui/src/panels/edit.rs+++ b/crates/ui-egui/src/panels/edit.rs@@ -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 => {}     } } diff --git a/crates/ui-egui/src/panels/format_text_ui.rs b/crates/ui-egui/src/panels/format_text_ui.rs--- a/crates/ui-egui/src/panels/format_text_ui.rs+++ b/crates/ui-egui/src/panels/format_text_ui.rs@@ -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));                 }             }diff --git a/crates/ui-egui/src/workflows/files.rs b/crates/ui-egui/src/workflows/files.rs--- a/crates/ui-egui/src/workflows/files.rs+++ b/crates/ui-egui/src/workflows/files.rs@@ -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),diff --git a/crates/ui-egui/tests/it/edit.rs b/crates/ui-egui/tests/it/edit.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/tests/it/edit.rs@@ -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()));+}diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs--- a/crates/ui-egui/tests/it/main.rs+++ b/crates/ui-egui/tests/it/main.rs@@ -39,6 +39,7 @@ mod create; mod date_format; mod dialog_chrome; mod document_links;+mod edit; mod editing; mod editor_taps; mod export;