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.rs
new 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;
