mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 04:22:20 +00:00
M7: fix matching Thai subset edits and stacked marks
This commit is contained in:
1 parent
f0c9901c39
commit
ace4321b81
12 files changed
+474
-24
No files matched your search
@@ -2,6 +2,8 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-10-16 · **Change:** minor (M7: matching Thai subset completion and stacked-mark editor layout; estimates unchanged)
|
||||
|
||||
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M2/gap 12: the Annex D.2 alias codes read as the ink they draw in both tables; the text oracle scores reading order; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 #2080 candidate: explicit search jumps join per-tab history; rail and control-channel Back/Forward; estimates unchanged)
|
||||
@@ -157,6 +159,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
| 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 |
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-16 (M7, Thai editing):** Existing Noto Sans Thai paragraphs use matching shaped outlines when their subset cannot shape the draft, avoiding variable-font embedding errors. The inline editor prefers complete matching layout tables for stacked vowels/tone marks. Regression coverage includes long rows, repeated edits, layout, undo/redo and save/reopen rendering; other Thai families and mixed contextual styles remain limited. Estimates unchanged.
|
||||
- **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged.
|
||||
- **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged.
|
||||
- **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged.
|
||||
@@ -495,6 +499,7 @@ Newest first: the date, what moved, and the new overall estimate.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-16 | minor | M7: matching Thai subset completion avoids variable-font embedding; complete matching preview tables preserve stacked marks. Other Thai families and mixed contextual styles remain limited; estimates unchanged. |
|
||||
| 2026-10-13 | minor | M9: separate page validation overlays preserve signature artwork, and snapshot tabs reuse instead of multiplying; baked captions remain unchanged (#79 open); estimates unchanged |
|
||||
| 2026-10-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. |
|
||||
| 2026-10-12 | minor | M6: preserve carriage returns in exported CSV field names and values; saved-PDF round-trip coverage. Estimates unchanged. |
|
||||
|
||||
@@ -5841,6 +5841,60 @@ fn thai_text_through_tools_saves_reopens_extracts_and_renders() {
|
||||
assert!(page_text(&mut a, reopened)[0].contains("แก้ไข น้ำ"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_thai_matching_face_edit_layout_undo_save_reopen_and_render() {
|
||||
use pdfcraft_cos::{Document, ObjRef, Object, SaveOptions, write_full};
|
||||
let dir = workdir("thai-existing");
|
||||
let mut source = Document::open(std::sync::Arc::new(fixture(1))).unwrap();
|
||||
// Synthetic source encoding lacks Thai; the approved matching family supplies the missing
|
||||
// contextual glyphs. No fixture/font bytes are checked in.
|
||||
source
|
||||
.update_dict(ObjRef::new(3, 0), |f| {
|
||||
f.set(b"BaseFont".to_vec(), Object::name("ABCDEF+NotoSansThai-Regular"));
|
||||
})
|
||||
.unwrap();
|
||||
std::fs::write(dir.join("thai-source.pdf"), write_full(&source, &SaveOptions::default()).unwrap()).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({"path": "thai-source.pdf"}))["doc"].as_u64().unwrap();
|
||||
let before = page_text(&mut a, doc);
|
||||
let text = "น้ำ ผู้ใช้ ๑๒๓";
|
||||
let args = json!({"doc": doc, "page": 1, "paragraph": 1, "text": text, "size": 12});
|
||||
if pdfcraft_fonts::document_thai_font().is_none() {
|
||||
assert!(a.call("text_edit", &args).is_err());
|
||||
assert_eq!(page_text(&mut a, doc), before);
|
||||
assert_eq!(ok(&mut a, "doc_info", json!({"doc": doc}))["document"]["dirty"], false);
|
||||
return;
|
||||
}
|
||||
ok(&mut a, "text_edit_layout", args.clone());
|
||||
assert_eq!(page_text(&mut a, doc), before, "layout is read-only");
|
||||
ok(&mut a, "text_edit", args);
|
||||
assert!(page_text(&mut a, doc)[0].contains(text));
|
||||
ok(&mut a, "edit_undo", json!({"doc": doc}));
|
||||
assert_eq!(page_text(&mut a, doc), before);
|
||||
ok(&mut a, "edit_redo", json!({"doc": doc}));
|
||||
assert!(page_text(&mut a, doc)[0].contains(text));
|
||||
let render = |a: &mut Automation, doc| {
|
||||
a.call("page_render", &json!({"doc": doc, "page": 1, "dpi": 144}))
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find_map(|c| match c {
|
||||
Content::Png { data, .. } => Some(data),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
let image = render(&mut a, doc);
|
||||
ok(&mut a, "doc_save", json!({"doc": doc, "path": "thai-edited.pdf"}));
|
||||
let reopened = ok(&mut a, "doc_open", json!({"path": "thai-edited.pdf"}))["doc"].as_u64().unwrap();
|
||||
assert_eq!(render(&mut a, reopened), image);
|
||||
assert!(page_text(&mut a, reopened)[0].contains(text));
|
||||
ok(&mut a, "text_edit", json!({"doc": reopened, "page": 1, "paragraph": 1, "text": "ปู่ น้ำ ๔๕๖"}));
|
||||
assert!(page_text(&mut a, reopened)[0].contains("ปู่ น้ำ ๔๕๖"));
|
||||
let unchanged = page_text(&mut a, reopened);
|
||||
assert!(a.call("text_edit", &json!({"doc": reopened, "page": 1, "paragraph": 1, "text": "น้ำ 😀"})).is_err());
|
||||
assert_eq!(page_text(&mut a, reopened), unchanged, "an unavailable glyph leaves the edit intact");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hebrew_text_through_tools_saves_reopens_extracts_and_renders() {
|
||||
let dir = workdir("hebrew-content");
|
||||
|
||||
@@ -737,6 +737,58 @@ fn type3_shaped(doc: &mut Document, clusters: &[&ShapedCluster], outlines: &Hash
|
||||
doc.add(Object::Dict(font))
|
||||
}
|
||||
|
||||
/// Existing Thai text uses absolute shaped glyph positions. Reuse Add Text's bounded outline
|
||||
/// writer instead of trying to embed the matching craft-fonts variable program as a static face.
|
||||
pub(crate) fn thai_glyph_resources(
|
||||
doc: &mut Document,
|
||||
fonts: &mut Dict,
|
||||
shaped: &pdfcraft_fonts::source_shaping::ShapedRun,
|
||||
) -> Result<Vec<(String, Vec<u8>)>, EditError> {
|
||||
if shaped.glyphs.len() > 65_534 {
|
||||
return Err(EditError::Invalid("shaped row exceeds its geometry budget".into()));
|
||||
}
|
||||
let mut outlines = HashMap::new();
|
||||
let mut clusters = Vec::new();
|
||||
let mut index = HashMap::new();
|
||||
let mut indices = Vec::new();
|
||||
for glyph in &shaped.glyphs {
|
||||
let id = glyph.id.to_u32();
|
||||
let key = (ShapedScript::Thai, id);
|
||||
if let std::collections::hash_map::Entry::Vacant(entry) = outlines.entry(key) {
|
||||
entry.insert(shaped_glyph(ShapedScript::Thai, id).map_err(|e| thai_error(e, &glyph.text))?);
|
||||
}
|
||||
let at = *index.entry((id, glyph.text.as_str())).or_insert_with(|| {
|
||||
let at = clusters.len();
|
||||
let width = outlines.get(&key).map_or(0.0, |g| g.width);
|
||||
clusters.push(ShapedCluster { script: ShapedScript::Thai, glyphs: vec![(id, [0.0, 0.0])], advance: width, text: glyph.text.clone() });
|
||||
at
|
||||
});
|
||||
indices.push(at);
|
||||
}
|
||||
// Validate all outlines before adding resources. Type 3 character codes are single bytes;
|
||||
// shard long rows and return the appropriate resource/code for every positioned glyph.
|
||||
let mut names = Vec::new();
|
||||
for chunk in clusters.chunks(GLYPHS_PER_FONT) {
|
||||
let refs: Vec<_> = chunk.iter().collect();
|
||||
let font = type3_shaped(doc, &refs, &outlines);
|
||||
doc.update_dict(font, |f| f.set(b"Name".to_vec(), Object::name("PCThai")))?;
|
||||
let mut name = format!("PCThai{}", font.num);
|
||||
while fonts.contains(name.as_bytes()) {
|
||||
name.push('_');
|
||||
}
|
||||
fonts.set(name.as_bytes().to_vec(), Object::Ref(font));
|
||||
names.push(name);
|
||||
}
|
||||
indices
|
||||
.into_iter()
|
||||
.map(|at| {
|
||||
let name = names.get(at / GLYPHS_PER_FONT).ok_or_else(|| EditError::Invalid("Thai font resource is missing".into()))?;
|
||||
let code = u8::try_from(at % GLYPHS_PER_FONT + 1).map_err(|_| EditError::Invalid("Thai glyph limit".into()))?;
|
||||
Ok((name.clone(), vec![code]))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -2813,6 +2813,36 @@ fn added_thai_text_wraps_clusters_and_survives_reopen() {
|
||||
assert!(arabic_font_names(&doc, 0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_thai_subset_uses_shaped_matching_face_without_embedding_variable_font() {
|
||||
let Some(_) = pdfcraft_fonts::document_thai_font() else { return };
|
||||
// Synthetic source subset: only one Thai letter is encoded. No binary fixture or font
|
||||
// is committed; the complete, openly licensed matching face is the optional build input.
|
||||
let mut doc = text_page("BT /F1 16 Tf 50 700 Td (A) Tj ET BT /F2 12 Tf 50 600 Td (ab) Tj ET");
|
||||
let cmap =
|
||||
doc.add(Object::Stream(Stream::flate(Dict::new(), b"1 begincodespacerange <00> <FF> endcodespacerange 1 beginbfchar <41> <0E19> endbfchar")));
|
||||
doc.update_dict(pdfcraft_cos::ObjRef::new(5, 0), |f| {
|
||||
f.set(b"BaseFont".to_vec(), Object::name("ABCDEF+NotoSansThai-Regular"));
|
||||
f.set(b"ToUnicode".to_vec(), Object::Ref(cmap));
|
||||
})
|
||||
.unwrap();
|
||||
let neighbour = text::text_lines(&doc, 0).unwrap()[1].clone();
|
||||
// More than 240 distinct logical clusters requires several single-byte Type 3 resources.
|
||||
let long: String =
|
||||
"กขคงจฉชซญดตถทนบปผฝพฟมยรลวศษสหฬอฮ".chars().flat_map(|c| ["ิ่", "ิ้", "ี่", "ี้", "ึ่", "ึ้", "ื่", "ื้"].map(move |marks| format!("{c}{marks}"))).collect();
|
||||
let draft = format!("{long} ผู้ใช้ ปู่ ๑๒๓");
|
||||
let style = text::BlockStyle { width: Some(14_400.0), ..Default::default() };
|
||||
text::rewrite_block(&mut doc, 0, 0, Some(&draft), &style).unwrap();
|
||||
let mut doc = reopen(&doc);
|
||||
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].editor_text, draft);
|
||||
assert_eq!(text::text_lines(&doc, 0).unwrap()[0].baseline(), [50.0, 700.0]);
|
||||
assert_eq!(text::text_lines(&doc, 0).unwrap().last().unwrap().text, neighbour.text);
|
||||
assert_eq!(text::text_lines(&doc, 0).unwrap().last().unwrap().baseline(), neighbour.baseline());
|
||||
let next = "Hello ผู้ใช้ น้ำ ๔๕๖";
|
||||
text::rewrite_block(&mut doc, 0, 0, Some(next), &text::BlockStyle::default()).unwrap();
|
||||
assert_eq!(text::text_blocks(&reopen(&doc), 0).unwrap()[0].editor_text, next);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_moves_preserve_glyph_bytes_and_unselected_text_state() {
|
||||
for content in [
|
||||
|
||||
@@ -48,7 +48,7 @@ pub struct TextLine {
|
||||
pub decodable: bool,
|
||||
/// Whether the font requires visual-order Indic text.
|
||||
pub visual_order: bool,
|
||||
/// The Japanese or Hebrew fallback face the font subsets, when the line is in one (see [`same_font`]).
|
||||
/// The generated fallback face the font subsets, when the line is in one (see [`same_font`]).
|
||||
face: Option<String>,
|
||||
/// The content stream piece the line starts in, and its operators (indices into the page's
|
||||
/// streams parsed joined).
|
||||
@@ -506,7 +506,7 @@ struct Ts {
|
||||
/// The operators that set the current fill colour (`g`, `rg`, `k`, or `cs` + `sc`/`scn`).
|
||||
fill: Vec<Op>,
|
||||
font: Option<(Vec<u8>, Rc<Metrics>)>,
|
||||
/// The Japanese or Hebrew fallback face the font subsets, when the font is one (see [`is_type3_fallback`]).
|
||||
/// The generated fallback face the font subsets, when the font is one (see [`is_type3_fallback`]).
|
||||
face: Option<String>,
|
||||
size: f64,
|
||||
char_spacing: f64,
|
||||
@@ -1304,14 +1304,14 @@ const JAPANESE_FONT_NAME: &str = "PCJapanese";
|
||||
/// The `/Name` of the Type 3 fonts generated for Hebrew text.
|
||||
const HEBREW_FONT_NAME: &str = "PCHebrew";
|
||||
|
||||
/// Whether `font` is one of those generated fonts (Japanese or Hebrew): a replacement longer than
|
||||
/// Whether `font` is one of those generated fonts (Japanese, Hebrew or Thai): a replacement longer than
|
||||
/// one of them is split across several, which read back as one line (see [`same_font`]).
|
||||
fn is_type3_fallback(font: &Dict) -> bool {
|
||||
font.name(b"Subtype") == Some(b"Type3")
|
||||
&& font.name(b"Name").is_some_and(|n| n == JAPANESE_FONT_NAME.as_bytes() || n == HEBREW_FONT_NAME.as_bytes())
|
||||
&& font.name(b"Name").is_some_and(|n| n == JAPANESE_FONT_NAME.as_bytes() || n == HEBREW_FONT_NAME.as_bytes() || n == b"PCThai")
|
||||
}
|
||||
|
||||
/// Whether a run shown in `font` (a resource name, and the Japanese or Hebrew fallback face it subsets, if
|
||||
/// Whether a run shown in `font` (a resource name, and the generated fallback face it subsets, if
|
||||
/// any) continues `line`: the same font resource, or another subset of the same face.
|
||||
fn same_font(line: &TextLine, font: &[u8], face: Option<&str>) -> bool {
|
||||
line.font.as_bytes() == font || (line.face.is_some() && line.face.as_deref() == face)
|
||||
|
||||
+112
-12
@@ -168,13 +168,65 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec<TextBlock> {
|
||||
blocks
|
||||
}
|
||||
|
||||
/// Whether the approved Thai face completes this source family, whose embedded subset cannot
|
||||
/// shape the replacement. A usable complete source program always takes precedence.
|
||||
fn matching_thai_outline(doc: &Document, fonts: &Dict, source: &TextLine, span: &str) -> bool {
|
||||
let Some(face) = pdfcraft_fonts::document_thai_font() else { return false };
|
||||
span.chars().any(|c| matches!(u32::from(c), 0x0e00..=0x0e7f))
|
||||
&& pdfcraft_fonts::embedding::matching_faces(&source.base_font).iter().any(|f| std::ptr::eq(*f, face))
|
||||
&& pdfcraft_fonts::embedding::covers(face.bytes, span)
|
||||
&& !fonts.get(source.font.as_bytes()).and_then(|f| doc.dict(f)).and_then(|f| pdfcraft_fonts::pdf::embedded_sfnt(doc, &f)).is_some_and(
|
||||
|bytes| {
|
||||
pdfcraft_fonts::embedding::validate(&bytes).is_ok()
|
||||
&& pdfcraft_fonts::source_shaping::has_thai_layout(&bytes)
|
||||
&& pdfcraft_fonts::embedding::covers(&bytes, span)
|
||||
&& span.split('\n').all(|row| pdfcraft_fonts::source_shaping::shape(&bytes, row).is_ok())
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Use the same Thai script selection and mark/cluster positioning as Add Text, including
|
||||
/// rows beginning with Latin or digits (guessing from their first character would select Latin).
|
||||
fn shape_thai_row(text: &str) -> Result<pdfcraft_fonts::source_shaping::ShapedRun, EditError> {
|
||||
if text.len() > 262_144 || text.contains(['\n', '\r']) {
|
||||
return Err(EditError::Invalid("shape one bounded text row at a time".into()));
|
||||
}
|
||||
let clusters =
|
||||
pdfcraft_fonts::shape_thai(text).map_err(|e| EditError::Invalid(format!("the matching Thai font cannot shape this text ({e:?})")))?;
|
||||
let mut glyphs = Vec::new();
|
||||
let mut advance = 0.0;
|
||||
for cluster in clusters {
|
||||
for (i, (id, [x, y])) in cluster.glyphs.into_iter().enumerate() {
|
||||
if glyphs.len() >= 65_534 {
|
||||
return Err(EditError::Invalid("shaped row exceeds its geometry budget".into()));
|
||||
}
|
||||
glyphs.push(pdfcraft_fonts::source_shaping::ShapedGlyph {
|
||||
id: id.into(),
|
||||
position: [advance + x, y],
|
||||
text: if i == 0 { cluster.text.clone() } else { String::new() },
|
||||
});
|
||||
}
|
||||
advance += cluster.advance;
|
||||
}
|
||||
if !advance.is_finite() {
|
||||
return Err(EditError::Invalid("shaped row exceeds its geometry budget".into()));
|
||||
}
|
||||
Ok(pdfcraft_fonts::source_shaping::ShapedRun { glyphs, advance })
|
||||
}
|
||||
|
||||
/// Shape in the source or an explicitly registered matching full face. Never use interface fonts.
|
||||
fn shape_source(
|
||||
doc: &Document,
|
||||
fonts: &Dict,
|
||||
outlined_thai: &std::collections::HashSet<String>,
|
||||
name: &str,
|
||||
text: &str,
|
||||
) -> Result<Option<(Vec<u8>, pdfcraft_fonts::source_shaping::ShapedRun, String)>, EditError> {
|
||||
if outlined_thai.contains(name) {
|
||||
let face = pdfcraft_fonts::document_thai_font().ok_or_else(|| EditError::Invalid("the matching Thai face is unavailable".into()))?;
|
||||
let shaped = shape_thai_row(text)?;
|
||||
return Ok(Some((face.bytes.to_vec(), shaped, face.family.to_owned())));
|
||||
}
|
||||
if !pdfcraft_fonts::source_shaping::required(text) {
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -393,15 +445,40 @@ pub(super) fn rewrite_styled_block(
|
||||
let mut fallback_names: std::collections::BTreeMap<(String, char), String> = std::collections::BTreeMap::new();
|
||||
let mut full_faces: HashMap<String, String> = HashMap::new();
|
||||
let mut programs: HashMap<String, Option<std::sync::Arc<Vec<u8>>>> = HashMap::new();
|
||||
let mut outlined_thai = std::collections::HashSet::new();
|
||||
for (range, index) in styled_spans {
|
||||
let source = lines.get(index).unwrap_or(first);
|
||||
for ch in draft.get(range).unwrap_or_default().chars() {
|
||||
let span = draft.get(range).unwrap_or_default();
|
||||
// Only complete the same Thai family, only when no face was explicitly selected,
|
||||
// and keep a usable embedded source program first. Never export interface fallback
|
||||
// fonts or change unrelated scripts' static-font embedding rules.
|
||||
let use_thai_outlines = style.resource.is_none()
|
||||
&& overrides.get(&index).and_then(|r| r.resource.as_ref()).is_none()
|
||||
&& matching_thai_outline(doc, &fonts, source, span);
|
||||
let outline_name = if use_thai_outlines {
|
||||
// A draft-only name prevents this choice from affecting another style span that
|
||||
// explicitly selects the original resource. It is never saved into the PDF.
|
||||
let mut name = format!("PCOutlineThai{index}");
|
||||
while metrics.contains_key(&name) {
|
||||
name.push('_');
|
||||
}
|
||||
if let Some(m) = metrics.get(&source.font).cloned() {
|
||||
metrics.insert(name.clone(), m);
|
||||
}
|
||||
outlined_thai.insert(name.clone());
|
||||
Some(name)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
for ch in span.chars() {
|
||||
if ch == '\n' {
|
||||
chars.push((ch, index, source.font.clone()));
|
||||
continue;
|
||||
}
|
||||
let s = ch.to_string();
|
||||
let name = if let Some(resource) = overrides.get(&index).and_then(|r| r.resource.as_ref()).or(style.resource.as_ref()) {
|
||||
let name = if let Some(name) = &outline_name {
|
||||
name.clone()
|
||||
} else if let Some(resource) = overrides.get(&index).and_then(|r| r.resource.as_ref()).or(style.resource.as_ref()) {
|
||||
if !metrics.get(resource).is_some_and(|m| source_encodes(m, &s)) {
|
||||
return Err(EditError::Invalid(format!("the selected font resource {resource} cannot show U+{:04X}", u32::from(ch))));
|
||||
}
|
||||
@@ -538,7 +615,7 @@ pub(super) fn rewrite_styled_block(
|
||||
}
|
||||
let segment = items.get(at..end).unwrap_or_default();
|
||||
let string: String = segment.iter().map(|v| v.0).collect();
|
||||
if let Some((_, shaped, _)) = shape_source(doc, &fonts, &item.2, &string)? {
|
||||
if let Some((_, shaped, _)) = shape_source(doc, &fonts, &outlined_thai, &item.2, &string)? {
|
||||
let source = lines.get(item.1).unwrap_or(first);
|
||||
let scale =
|
||||
overrides.get(&item.1).and_then(|r| r.scale).or(style.scale).map_or(source.origin.state.scale, |s| (s / 100.0).clamp(0.1, 10.0));
|
||||
@@ -654,7 +731,7 @@ pub(super) fn rewrite_styled_block(
|
||||
}
|
||||
segment_advances.insert((row_index, at), row.get(at..end).unwrap_or_default().iter().map(&advance).sum::<f64>());
|
||||
let string: String = row.get(at..end).unwrap_or_default().iter().map(|v| v.0).collect();
|
||||
if let Some((bytes, shaped, family)) = shape_source(doc, &fonts, &item.2, &string)? {
|
||||
if let Some((bytes, shaped, family)) = shape_source(doc, &fonts, &outlined_thai, &item.2, &string)? {
|
||||
shaped_runs.insert((row_index, at), (bytes, shaped, family));
|
||||
}
|
||||
at = end;
|
||||
@@ -662,9 +739,20 @@ pub(super) fn rewrite_styled_block(
|
||||
}
|
||||
let mut glyph_resources = HashMap::new();
|
||||
for (key, (bytes, shaped, family)) in &shaped_runs {
|
||||
let mappings: Vec<_> = shaped.glyphs.iter().map(|g| (g.text.clone(), g.id)).collect();
|
||||
let resource = pdfcraft_fonts::embedding::embed_mapping(doc, &mut fonts, bytes, family, &mappings).map_err(EditError::Invalid)?;
|
||||
glyph_resources.insert(*key, resource);
|
||||
let name = rows.get(key.0).and_then(|row| row.get(key.1)).map(|c| &c.2);
|
||||
let resources = if name.is_some_and(|n| outlined_thai.contains(n)) {
|
||||
crate::added::thai_glyph_resources(doc, &mut fonts, shaped)?
|
||||
} else {
|
||||
let mappings: Vec<_> = shaped.glyphs.iter().map(|g| (g.text.clone(), g.id)).collect();
|
||||
let resource = pdfcraft_fonts::embedding::embed_mapping(doc, &mut fonts, bytes, family, &mappings).map_err(EditError::Invalid)?;
|
||||
(0..shaped.glyphs.len())
|
||||
.map(|i| {
|
||||
let code = u16::try_from(i + 1).map_err(|_| EditError::Invalid("shaped glyph limit".into()))?;
|
||||
Ok((resource.clone(), code.to_be_bytes().to_vec()))
|
||||
})
|
||||
.collect::<Result<Vec<_>, EditError>>()?
|
||||
};
|
||||
glyph_resources.insert(*key, resources);
|
||||
}
|
||||
let n = pdfcraft_content::num;
|
||||
let mut block_ops = vec![Op::new("q", vec![])];
|
||||
@@ -748,29 +836,41 @@ pub(super) fn rewrite_styled_block(
|
||||
tm[5] += ay;
|
||||
let mut segment_width = segment_advances.get(&(row_index, pos)).copied().unwrap_or(0.0);
|
||||
if let Some((_, shaped, _)) = shaped_runs.get(&(row_index, pos)) {
|
||||
let resource = glyph_resources.get(&(row_index, pos)).ok_or_else(|| EditError::Invalid("shaped font is missing".into()))?;
|
||||
let resources = glyph_resources.get(&(row_index, pos)).ok_or_else(|| EditError::Invalid("shaped font is missing".into()))?;
|
||||
let mut properties = Dict::new();
|
||||
properties.set(b"ActualText".to_vec(), Object::String(PdfString::text(&string)));
|
||||
block_ops.push(Op::new("BDC", vec![Object::name("Span"), Object::Dict(properties)]));
|
||||
state.font = Some((resource.clone(), size_of(source, item.1)));
|
||||
// Absolute glyph matrices preserve marks and contextual positions; font-program
|
||||
// codes are separate from Unicode and ToUnicode/ActualText keep logical text.
|
||||
let size = size_of(source, item.1);
|
||||
let mut extra = 0.0;
|
||||
let thai = outlined_thai.contains(&item.2);
|
||||
let mut cluster_spacing = 0.0;
|
||||
for (i, glyph) in shaped.glyphs.iter().enumerate() {
|
||||
if thai && !glyph.text.is_empty() {
|
||||
// Thai cluster text belongs to its first glyph. Defer its spacing
|
||||
// until the next cluster so the vowel/tone glyphs stay on their base.
|
||||
extra += cluster_spacing;
|
||||
cluster_spacing =
|
||||
state.char_spacing * glyph.text.chars().count() as f64 + state.word_spacing * glyph.text.matches(' ').count() as f64;
|
||||
}
|
||||
let mut matrix = tm;
|
||||
let x = (glyph.position[0] * size + extra) * state.scale;
|
||||
let y = glyph.position[1] * size;
|
||||
matrix[4] += matrix[0] * x + matrix[2] * y;
|
||||
matrix[5] += matrix[1] * x + matrix[3] * y;
|
||||
let code = u16::try_from(i + 1).map_err(|_| EditError::Invalid("shaped glyph limit".into()))?;
|
||||
let (resource, code) = resources.get(i).ok_or_else(|| EditError::Invalid("shaped glyph resource is missing".into()))?;
|
||||
state.font = Some((resource.clone(), size));
|
||||
block_ops.push(Op::new("BT", vec![]));
|
||||
block_ops.extend(state.ops());
|
||||
block_ops.push(Op::new("Tm", matrix.map(n).to_vec()));
|
||||
block_ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(code.to_be_bytes().to_vec()))]));
|
||||
block_ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(code.clone()))]));
|
||||
block_ops.push(Op::new("ET", vec![]));
|
||||
extra += state.char_spacing * glyph.text.chars().count() as f64 + state.word_spacing * glyph.text.matches(' ').count() as f64;
|
||||
if !thai {
|
||||
extra += state.char_spacing * glyph.text.chars().count() as f64 + state.word_spacing * glyph.text.matches(' ').count() as f64;
|
||||
}
|
||||
}
|
||||
extra += cluster_spacing;
|
||||
block_ops.push(Op::new("EMC", vec![]));
|
||||
segment_width = (shaped.advance * size + extra) * state.scale * source.origin.k;
|
||||
} else {
|
||||
|
||||
@@ -238,6 +238,27 @@ mod tests {
|
||||
mod thai_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn thai_vowels_and_tones_occupy_separate_vertical_bands() {
|
||||
if crate::document_thai_font().is_none() {
|
||||
return;
|
||||
}
|
||||
for text in ["กิ่", "กี้", "กึ้", "น้ำ", "น่ำ", "น้ํา", "นํ้า"] {
|
||||
let mut marks = Vec::new();
|
||||
for cluster in shape_thai(text).unwrap() {
|
||||
for (id, [_, y]) in cluster.glyphs {
|
||||
let g = shaped_glyph(ShapedScript::Thai, id).unwrap();
|
||||
if g.width == 0.0 && !g.contours.is_empty() {
|
||||
marks.push([g.bbox[1] + y, g.bbox[3] + y]);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(marks.len(), 2, "{text}: vowel/Sara Am dot and tone");
|
||||
marks.sort_by(|a, b| a[0].total_cmp(&b[0]));
|
||||
assert!(marks[0][1] <= marks[1][0] + 0.005, "{text}: lower and upper marks overlap: {marks:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thai_clusters_preserve_sara_am_and_stacked_marks() {
|
||||
if crate::document_thai_font().is_none() {
|
||||
@@ -245,7 +266,7 @@ mod thai_tests {
|
||||
assert!(!thai_has('ก'));
|
||||
return;
|
||||
}
|
||||
for text in ["น้ำ", "กิ่", "ผู้ใช้", "ปู่", "เก้า", "สวัสดีภาษาไทย", "๑๒๓", ""]
|
||||
for text in ["น้ำ", "น้ํา", "นํ้า", "กิ่", "ผู้ใช้", "ปู่", "เก้า", "สวัสดีภาษาไทย", "๑๒๓", ""]
|
||||
{
|
||||
let clusters = shape_thai(text).unwrap();
|
||||
assert_eq!(clusters.iter().map(|c| c.text.as_str()).collect::<String>(), text);
|
||||
|
||||
@@ -22,6 +22,13 @@ pub fn required(text: &str) -> bool {
|
||||
.any(|c| matches!(u32::from(c), 0x0300..=0x036f | 0x0590..=0x0fff | 0x1780..=0x17ff | 0x200c..=0x200f | 0x202a..=0x202e | 0x2066..=0x2069))
|
||||
}
|
||||
|
||||
/// Whether a Thai source program retains its mark-positioning table.
|
||||
/// Character coverage alone does not show that stacked vowels and tones can be laid out.
|
||||
/// OpenType Thai requires `mark`/`mkmk` (GPOS); `ccmp` substitution (GSUB) is optional.
|
||||
pub fn has_thai_layout(bytes: &[u8]) -> bool {
|
||||
FontRef::new(bytes).is_ok_and(|f| f.gpos().is_ok())
|
||||
}
|
||||
|
||||
pub fn shape(bytes: &[u8], text: &str) -> Result<ShapedRun, String> {
|
||||
if text.len() > 262_144 || text.contains(['\n', '\r']) {
|
||||
return Err("shape one bounded text row at a time".into());
|
||||
@@ -30,7 +37,12 @@ pub fn shape(bytes: &[u8], text: &str) -> Result<ShapedRun, String> {
|
||||
return Ok(ShapedRun { glyphs: Vec::new(), advance: 0.0 });
|
||||
}
|
||||
let face = FontRef::new(bytes).map_err(|e| e.to_string())?;
|
||||
if text.chars().any(|c| matches!(u32::from(c), 0x0600..=0x06ff | 0x0750..=0x077f | 0x08a0..=0x0fff | 0x1780..=0x17ff)) && face.gsub().is_err() {
|
||||
if text.chars().any(|c| matches!(u32::from(c), 0x0e00..=0x0e7f)) && face.gpos().is_err() {
|
||||
return Err("the source subset has no Thai mark-positioning table (GPOS); select its full font".into());
|
||||
}
|
||||
if text.chars().any(|c| matches!(u32::from(c), 0x0600..=0x06ff | 0x0750..=0x077f | 0x08a0..=0x0dff | 0x0e80..=0x0fff | 0x1780..=0x17ff))
|
||||
&& face.gsub().is_err()
|
||||
{
|
||||
return Err("the source subset has no contextual substitution tables; select its full font".into());
|
||||
}
|
||||
let data = ShaperData::new(&face);
|
||||
@@ -74,3 +86,27 @@ pub fn shape(bytes: &[u8], text: &str) -> Result<ShapedRun, String> {
|
||||
}
|
||||
Ok(ShapedRun { glyphs, advance: x })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn thai_positioning_does_not_require_optional_gsub() {
|
||||
let Some(face) = crate::document_thai_font() else { return };
|
||||
let mut bytes = face.bytes.to_vec();
|
||||
// Keep the actual GPOS anchors and outlines while removing optional substitutions.
|
||||
// Table payloads stay at their original offsets; no font fixture is committed.
|
||||
let count = u16::from_be_bytes([bytes[4], bytes[5]]) as usize;
|
||||
let tables: Vec<_> = bytes[12..12 + count * 16].as_chunks::<16>().0.iter().filter(|t| &t[..4] != b"GSUB").map(|t| t.to_vec()).collect();
|
||||
bytes[4..6].copy_from_slice(&(tables.len() as u16).to_be_bytes());
|
||||
for (i, table) in tables.iter().enumerate() {
|
||||
bytes[12 + i * 16..28 + i * 16].copy_from_slice(table);
|
||||
}
|
||||
assert!(FontRef::new(&bytes).unwrap().gsub().is_err());
|
||||
assert!(has_thai_layout(&bytes));
|
||||
let shaped = shape(&bytes, "กิ่ น้ำ").unwrap();
|
||||
assert!(shaped.advance > 0.0);
|
||||
assert!(shaped.glyphs.iter().all(|g| g.position.iter().all(|p| p.is_finite())));
|
||||
}
|
||||
}
|
||||
@@ -1129,6 +1129,7 @@ pub(crate) fn install_source_fonts(ctx: &egui::Context, doc: &pdfcraft_engine::D
|
||||
}
|
||||
for program in &programs {
|
||||
let Some(name) = families.get(&program.resource) else { continue };
|
||||
let complete_thai = thai_subset_needs_layout(&program.bytes, &program.face);
|
||||
// Word-created PDFs often split one face into several subsets. Use all of the
|
||||
// document's matching subsets before falling back for newly inserted characters.
|
||||
let mut stack = vec![name.clone()];
|
||||
@@ -1136,11 +1137,24 @@ pub(crate) fn install_source_fonts(ctx: &egui::Context, doc: &pdfcraft_engine::D
|
||||
programs
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
pdfcraft_fonts::embedding::face_key(&p.face) == pdfcraft_fonts::embedding::face_key(&program.face)
|
||||
&& p.resource != program.resource
|
||||
p.resource != program.resource
|
||||
&& (pdfcraft_fonts::embedding::face_key(&p.face) == pdfcraft_fonts::embedding::face_key(&program.face)
|
||||
|| complete_thai && p.resource.starts_with("PCApproved") && matching_thai_face(&p.face))
|
||||
})
|
||||
.filter_map(|p| families.get(&p.resource).cloned()),
|
||||
);
|
||||
// A Thai subset can contain every typed character while lacking the layout tables
|
||||
// that raise tone marks above vowels/Sara Am. It would win ordinary glyph fallback
|
||||
// and keep those marks on one level. Prefer only its approved complete matching face;
|
||||
// a complete source with usable tables, and every other family, keeps the existing order.
|
||||
if complete_thai {
|
||||
let approved: Vec<_> = programs
|
||||
.iter()
|
||||
.filter(|p| p.resource.starts_with("PCApproved") && matching_thai_face(&p.face))
|
||||
.filter_map(|p| families.get(&p.resource))
|
||||
.collect();
|
||||
stack.sort_by_key(|name| !approved.contains(&name));
|
||||
}
|
||||
if let Some(fallback) = definitions.families.get(&FontFamily::Proportional) {
|
||||
stack.extend(fallback.iter().cloned());
|
||||
}
|
||||
@@ -1152,6 +1166,28 @@ pub(crate) fn install_source_fonts(ctx: &egui::Context, doc: &pdfcraft_engine::D
|
||||
families
|
||||
}
|
||||
|
||||
fn thai_subset_needs_layout(bytes: &[u8], face: &str) -> bool {
|
||||
use skrifa::MetadataProvider;
|
||||
if !matching_thai_face(face) {
|
||||
return false;
|
||||
}
|
||||
let Some(thai) = pdfcraft_fonts::document_thai_font() else { return false };
|
||||
let (Ok(source), Ok(full)) = (skrifa::FontRef::new(bytes), skrifa::FontRef::new(thai.bytes)) else { return true };
|
||||
// New marks must be shaped with their base in one face, not picked individually from
|
||||
// the fallback stack. A complete source keeps priority; an incomplete Thai cmap does not.
|
||||
!pdfcraft_fonts::source_shaping::has_thai_layout(bytes)
|
||||
|| !full
|
||||
.charmap()
|
||||
.mappings()
|
||||
.filter(|(cp, _)| matches!(*cp, 0x0e00..=0x0e7f))
|
||||
.all(|(cp, _)| source.charmap().map(cp).is_some_and(|g| g.to_u32() != 0))
|
||||
}
|
||||
|
||||
fn matching_thai_face(face: &str) -> bool {
|
||||
let Some(thai) = pdfcraft_fonts::document_thai_font() else { return false };
|
||||
pdfcraft_fonts::embedding::matching_faces(face).iter().any(|f| std::ptr::eq(*f, thai))
|
||||
}
|
||||
|
||||
fn editor_job(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f32, width: f32) -> egui::text::LayoutJob {
|
||||
let mut job = egui::text::LayoutJob::default();
|
||||
// An unchanged PDF row already has an authoritative line break. Do not rewrap it
|
||||
@@ -1444,6 +1480,103 @@ pub(crate) fn caret_color_on(fill: Color32) -> Color32 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn thai_editor_keeps_complete_sources_but_completes_new_marks_as_one_face() {
|
||||
use skrifa::MetadataProvider;
|
||||
let Some(thai) = pdfcraft_fonts::document_thai_font() else { return };
|
||||
assert!(!thai_subset_needs_layout(thai.bytes, "NotoSansThai-Regular"));
|
||||
// Keep valid GPOS, but remove mai tho from the Unicode map. Per-character fallback
|
||||
// would put this newly typed tone in a different shaping run from its source base.
|
||||
let full = skrifa::FontRef::new(thai.bytes).unwrap();
|
||||
let mappings: Vec<_> = full.charmap().mappings().filter(|(cp, _)| *cp != 0x0e49).collect();
|
||||
let mut cmap = vec![0, 0, 0, 1, 0, 3, 0, 10, 0, 0, 0, 12];
|
||||
cmap.extend_from_slice(&12u16.to_be_bytes());
|
||||
cmap.extend_from_slice(&0u16.to_be_bytes());
|
||||
cmap.extend_from_slice(&u32::try_from(16 + mappings.len() * 12).unwrap().to_be_bytes());
|
||||
cmap.extend_from_slice(&0u32.to_be_bytes());
|
||||
cmap.extend_from_slice(&u32::try_from(mappings.len()).unwrap().to_be_bytes());
|
||||
for (cp, id) in mappings {
|
||||
for v in [cp, cp, id.to_u32()] {
|
||||
cmap.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
}
|
||||
let mut bytes = thai.bytes.to_vec();
|
||||
while !bytes.len().is_multiple_of(4) {
|
||||
bytes.push(0);
|
||||
}
|
||||
let offset = u32::try_from(bytes.len()).unwrap();
|
||||
let count = u16::from_be_bytes([bytes[4], bytes[5]]) as usize;
|
||||
let record = bytes[12..12 + count * 16].as_chunks_mut::<16>().0.iter_mut().find(|t| &t[..4] == b"cmap").unwrap();
|
||||
record[8..12].copy_from_slice(&offset.to_be_bytes());
|
||||
record[12..16].copy_from_slice(&u32::try_from(cmap.len()).unwrap().to_be_bytes());
|
||||
bytes.extend(cmap);
|
||||
assert!(pdfcraft_fonts::source_shaping::has_thai_layout(&bytes));
|
||||
assert!(!pdfcraft_fonts::embedding::covers(&bytes, "้"));
|
||||
assert!(thai_subset_needs_layout(&bytes, "NotoSansThai-Regular"));
|
||||
assert!(!thai_subset_needs_layout(&bytes, "UnrelatedFamily"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thai_editor_uses_complete_matching_layout_for_stacked_marks() {
|
||||
use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream, write_full};
|
||||
let Some(thai) = pdfcraft_fonts::document_thai_font() else { return };
|
||||
// Synthetic subset: keep all outlines/cmap, remove only GSUB/GPOS. Existing characters
|
||||
// still win ordinary glyph fallback, but the two upper marks no longer stack correctly.
|
||||
let mut subset = thai.bytes.to_vec();
|
||||
let count = u16::from_be_bytes([subset[4], subset[5]]) as usize;
|
||||
let tables: Vec<_> =
|
||||
subset[12..12 + count * 16].as_chunks::<16>().0.iter().filter(|t| &t[..4] != b"GSUB" && &t[..4] != b"GPOS").map(|t| t.to_vec()).collect();
|
||||
subset[4..6].copy_from_slice(&(tables.len() as u16).to_be_bytes());
|
||||
for (i, table) in tables.iter().enumerate() {
|
||||
subset[12 + i * 16..28 + i * 16].copy_from_slice(table);
|
||||
}
|
||||
let (mut app, _) = preview_app();
|
||||
let mut pdf = Document::open(app.session.get(app.views[0].id).unwrap().bytes.clone()).unwrap();
|
||||
let page = pdfcraft_model::pages(&pdf).remove(0);
|
||||
let resource = app.session.get(app.views[0].id).unwrap().text_lines(0)[0].font.clone();
|
||||
let program = pdf.add(Object::Stream(Stream::from_raw(Dict::new(), subset)));
|
||||
let descriptor =
|
||||
Dict::from_iter([(b"FontFile2".to_vec(), Object::Ref(program)), (b"FontName".to_vec(), Object::name("ABCDEF+NotoSansThai-Regular"))]);
|
||||
let font = Dict::from_iter([
|
||||
(b"Type".to_vec(), Object::name("Font")),
|
||||
(b"Subtype".to_vec(), Object::name("TrueType")),
|
||||
(b"BaseFont".to_vec(), Object::name("ABCDEF+NotoSansThai-Regular")),
|
||||
(b"FontDescriptor".to_vec(), Object::Dict(descriptor)),
|
||||
]);
|
||||
let mut resources = pdf.dict(page.dict.get(b"Resources").unwrap()).unwrap();
|
||||
let mut fonts = pdf.dict(resources.get(b"Font").unwrap()).unwrap();
|
||||
fonts.set(resource.as_bytes().to_vec(), Object::Dict(font));
|
||||
resources.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
pdf.update_dict(page.obj, |p| p.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap();
|
||||
app.open_bytes("thai-preview.pdf", None, write_full(&pdf, &SaveOptions::default()).unwrap()).unwrap();
|
||||
let doc = app.session.get(app.views.last().unwrap().id).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
ctx.begin_pass(Default::default());
|
||||
let families = install_source_fonts(&ctx, doc, 0);
|
||||
ctx.end_pass().textures_delta.clear();
|
||||
ctx.begin_pass(Default::default());
|
||||
let name = families.get(&resource).unwrap();
|
||||
let family = FontFamily::Name(name.clone().into());
|
||||
let definitions = ctx.fonts(|f| f.definitions().clone());
|
||||
let stack = definitions.families.get(&family).unwrap();
|
||||
let preferred = stack.first().unwrap();
|
||||
assert!(definitions.font_data.get(preferred).unwrap().font.as_ref() == thai.bytes, "the editor must shape with the complete matching face");
|
||||
let text = "กิ่ น้ำ น่ำ กี้ กึ้";
|
||||
let actual = ctx.fonts_mut(|f| f.layout_no_wrap(text.into(), FontId::new(48.0, family), Color32::BLACK));
|
||||
// Compare the mark offsets against the complete face alone, independently of the
|
||||
// source-font stack; this also catches falling back one character at a time.
|
||||
let expected_family = FontFamily::Name("thai-complete-only".into());
|
||||
let mut expected_definitions = definitions.clone();
|
||||
expected_definitions.families.insert(expected_family.clone(), vec![preferred.clone()]);
|
||||
ctx.set_fonts(expected_definitions);
|
||||
ctx.end_pass().textures_delta.clear();
|
||||
ctx.begin_pass(Default::default());
|
||||
let expected = ctx.fonts_mut(|f| f.layout_no_wrap(text.into(), FontId::new(48.0, expected_family), Color32::BLACK));
|
||||
let offsets = |galley: &egui::Galley| galley.rows.iter().flat_map(|r| r.glyphs.iter().map(|g| g.uv_rect.offset)).collect::<Vec<_>>();
|
||||
assert_eq!(offsets(&actual), offsets(&expected));
|
||||
ctx.end_pass().textures_delta.clear();
|
||||
}
|
||||
|
||||
/// An app with one text document open, and an editor on its first paragraph.
|
||||
fn preview_app() -> (crate::PdfCraftApp, LineEditor) {
|
||||
let mut app = crate::PdfCraftApp::new();
|
||||
|
||||
@@ -37,6 +37,22 @@ this works only when that build input actually contains the full face. This chan
|
||||
add a font binary or change the release font pin. Fonts shared by releases belong in
|
||||
`storytold/craft-fonts` under the repository's asset rules.
|
||||
|
||||
For an existing Noto Sans Thai paragraph whose subset cannot shape the draft, the matching
|
||||
approved Thai face supplies positioned outlines, using the same Thai shaping as Add Text.
|
||||
It does not attempt to embed the shared variable font as a static program. Stacked vowels,
|
||||
tone marks and Sara Am keep their OpenType positions, including after save/reopen and repeated
|
||||
edits; logical text remains available for copying and searching. The inline editor also prefers
|
||||
that complete matching face when the subset lacks GPOS mark-positioning tables or Thai characters,
|
||||
so newly typed marks stay in the same shaping run as their base. A usable complete source program keeps precedence.
|
||||
This requires the optional Thai build input and applies to matching-family paragraph edits;
|
||||
other Thai families still need their complete source font, and mixed contextual styles retain
|
||||
the restrictions below. No font asset or release pin is changed.
|
||||
Thai shaping reuses the existing [HarfRust](https://github.com/harfbuzz/harfrust) shaper and
|
||||
the font's `mark`/`mkmk` GPOS anchors from the
|
||||
[OpenType Thai model](https://learn.microsoft.com/en-us/typography/script-development/thai).
|
||||
Vertical mark offsets are not guessed or hardcoded; GSUB `ccmp` substitution is optional for
|
||||
Thai and its absence alone no longer refuses a source face with usable positioning.
|
||||
|
||||
Rewrapped Chinese and Japanese text, which has no spaces between words, may break between any
|
||||
two characters. A row never begins with closing punctuation, a closing bracket or quote, a small
|
||||
kana or the prolonged sound mark, and never ends with an opening bracket or quote or a currency
|
||||
|
||||
+4
-1
@@ -1,5 +1,7 @@
|
||||
# PdfCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-10-16 · **Change:** minor (M7: matching Thai subset completion and stacked-mark editor layout; estimates unchanged)
|
||||
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up, 2,528 entries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (M2/M6: spot-colour images convert once per pixel value; widget placement and the Fields panel scale with the field count; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 candidate: coordinate control clicks on slow frames and coalesced text selection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
@@ -118,7 +120,7 @@ total matches the parity estimate.
|
||||
| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.<br>Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page |
|
||||
| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.<br>Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. A refused restyle or resize of a text box another program drew leaves that comment unchanged (#1327). Make Current Properties Default on an arrow updates arrows, not plain lines (#1302). Flattening keeps a visible comment it cannot draw (#1317). Missing: custom stamps, replace-text proposals, summary layouts with the page |
|
||||
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
|
||||
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.<br>Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
|
||||
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done since: matching Noto Sans Thai subset completion uses positioned outlines; the inline editor uses complete matching layout tables for stacked marks. Other Thai families still need the complete source font. Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.<br>Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
|
||||
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.<br>Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps when the baselines differ; a second copy on the same baseline is still replaced. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
|
||||
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat |
|
||||
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. |
|
||||
@@ -179,6 +181,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-16 | minor | M7: existing Noto Sans Thai paragraph edits reuse shaped outlines instead of embedding the matching variable font; inline subset previews prefer the complete matching layout tables. Other Thai families and mixed contextual runs remain limited; estimates unchanged. |
|
||||
| 2026-10-15 | minor | M9 #1895: generic appearance rewriting deferred; separate validation overlays preserve original artwork, exports and printing. Baked captions remain unchanged (#79 open). Estimates unchanged. |
|
||||
| 2026-10-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. |
|
||||
| 2026-10-12 | minor | M6: preserve carriage returns in exported CSV field names and values; saved-PDF round-trip coverage. Estimates unchanged. |
|
||||
|
||||
@@ -1866,8 +1866,8 @@ milestone = "M7"
|
||||
status = "partial"
|
||||
commands = ["edit.edit_text"]
|
||||
tools = ["text_lines", "text_paragraphs", "text_edit", "text_edit_layout", "text_font_files", "text_font_import", "text_fonts"]
|
||||
evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/editing.rs::editing_existing_text_in_place", "crates/automation/tests/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/ui.rs::the_edit_tab_turns_on_edit_text_and_images"]
|
||||
notes = "The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; see docs/editing-existing-text.md"
|
||||
evidence = ["crates/edit/src/tests.rs::existing_thai_subset_uses_shaped_matching_face_without_embedding_variable_font", "crates/automation/tests/automation.rs::existing_thai_matching_face_edit_layout_undo_save_reopen_and_render", "crates/ui-egui/src/edit_text_ui.rs::thai_editor_uses_complete_matching_layout_for_stacked_marks", "crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/editing.rs::editing_existing_text_in_place", "crates/automation/tests/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/ui.rs::the_edit_tab_turns_on_edit_text_and_images"]
|
||||
notes = "Matching Noto Sans Thai paragraph subsets use shaped outlines without embedding the variable face; editor previews prefer complete matching Thai layout tables for stacked marks. Other Thai families require their full source face; mixed contextual styles remain limited. The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; see docs/editing-existing-text.md"
|
||||
|
||||
[[feature]]
|
||||
id = "edit.text-reflow"
|
||||
|
||||
Reference in new issue
Block a user