Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion ATTRIBUTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE).

## In this repository (272)
## In this repository (273)

| Asset | Title | Author | Licence | Source | Used for |
|---|---|---|---|---|---|
Expand All @@ -24,6 +24,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `crates/ui-egui/src/i18n/bg.tsv` | Bulgarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/kk.tsv` | Kazakh interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources | Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/ja.tsv` | Japanese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/ko.tsv` | Korean interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Korean UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/ar.tsv` | Arabic interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
Expand Down
11 changes: 11 additions & 0 deletions ATTRIBUTION.toml
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,17 @@ kind = "translation"
usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "bc6d123bdb34d5d7c22717fed1a86ff20e7e493acc1e155e61e61e2dc74459aa"

[[asset]]
path = "crates/ui-egui/src/i18n/ko.tsv"
title = "Korean interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Korean UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "cd438b905eafacfb77472397e7e38ab546d8a0dbf62ca5e7b6cfb032ed6caa19"

[[asset]]
path = "crates/ui-egui/src/i18n/uk.tsv"
title = "Ukrainian interface translation catalog"
Expand Down
3 changes: 2 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ union). Detail and script notes: [localization-parity.md](docs/localization-pari
| Indonesian | `id` | 0% | none |
| Japanese | `ja` | 99.1% | partial |
| German | `de` | 99.3% | partial |
| Korean | `ko` | 0% | none |
| Korean | `ko` | 100% of current union | partial (AI-drafted, one reviewer; no help) |
| Vietnamese | `vi` | 100% of current union | partial (dialogs / tooltips; no help) |

Also shipped: Traditional Chinese, Russian, Bulgarian, Telugu, Hungarian, Ukrainian, Italian,
Expand Down Expand Up @@ -490,6 +490,7 @@ Newest first: the date, what moved, and the new overall estimate.

- M13: common command, batch, inspection and bounded preview tools share the existing confined automation path. MCP tools carry complete hints and reject unknown top-level keys as invalid parameters.
- **2026-10-10 (UI docking):** Tools and Inspector can be regrouped, split beside the PDF, or floated inside the app window. Existing toggles reopen panels at their saved locations; UI commands arrange/reset panels, and preferences retain their layout. Read/full-screen modes preserve the focused document view. No milestone percentage change.
- **2026-10-15 (Korean):** Korean interface catalog, Nanum Gothic UI font, installed-font fallback and Hangul text written into PDFs (Add text, replaced lines; Type 3 from craft-fonts). Reviewed by a Korean-speaking contributor with Google Gemini (55 edits: Redact→민감 정보 지우기, Sanitize→보안 정리, Flatten→병합, Output intent→출력 조건). No milestone percentage change.

## Revision history

Expand Down
54 changes: 48 additions & 6 deletions crates/edit/src/added.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ use std::collections::{HashMap, HashSet};

use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
use pdfcraft_fonts::{
GlyphError, GlyphOutline, ShapedCluster, ShapedScript, Std14, literal, shape_arabic, shape_hebrew, shape_thai, shaped_glyph, win_ansi,
GlyphError, GlyphOutline, ShapedCluster, ShapedScript, Std14, literal, shape_arabic, shape_hangul, shape_hebrew, shape_thai, shaped_glyph,
win_ansi,
};
use unicode_bidi::{Level, ParagraphBidiInfo};

Expand Down Expand Up @@ -244,6 +245,7 @@ fn wrapped(t: &AddedText) -> Vec<(String, bool)> {
// Arabic or Hebrew: laid out with the bidirectional algorithm and shaped.
let rtl_script = para.chars().any(|c| is_arabic(c) || is_hebrew(c));
let thai = para.chars().any(is_thai);
let hangul = para.chars().any(is_hangul);
if thai
&& !rtl_script
&& let Ok(lines) = thai_lines(t, para, width)
Expand All @@ -256,13 +258,16 @@ fn wrapped(t: &AddedText) -> Vec<(String, bool)> {
// sum of its words' and shaping stays linear in the paragraph's length. The space is the
// paragraph's; a space between Latin words is drawn in the item's font, a few hundredths
// of an em apart.
let measure = |s: &str| if rtl_script || thai { shaped_width(t, s, rtl) } else { t.family.width(s, t.size, t.bold, t.italic) };
let measure = |s: &str| if rtl_script || thai || hangul { shaped_width(t, s, rtl) } else { t.family.width(s, t.size, t.bold, t.italic) };
let space = measure(" ");
let (mut line, mut line_w) = (String::new(), 0.0);
for word in para.split(' ') {
let candidate = if line.is_empty() { word.to_string() } else { format!("{line} {word}") };
let candidate_w =
if rtl_script || thai { if line.is_empty() { measure(word) } else { line_w + space + measure(word) } } else { measure(&candidate) };
let candidate_w = if rtl_script || thai || hangul {
if line.is_empty() { measure(word) } else { line_w + space + measure(word) }
} else {
measure(&candidate)
};
if !line.is_empty() && candidate_w > width {
out.push((std::mem::take(&mut line), rtl));
line = word.to_string();
Expand Down Expand Up @@ -310,6 +315,13 @@ fn is_thai(c: char) -> bool {
matches!(u32::from(c), 0x0E00..=0x0E7F)
}

/// Hangul syllables, conjoining jamo (and their extensions), compatibility jamo and half-width
/// jamo: an item with any of them is shaped with the craft-fonts Hangul face. Hanja and Korean
/// punctuation outside WinAnsiEncoding are not included.
pub(crate) fn is_hangul(c: char) -> bool {
matches!(u32::from(c), 0x1100..=0x11FF | 0x3130..=0x318F | 0xA960..=0xA97F | 0xAC00..=0xD7FF | 0xFFA0..=0xFFDC)
}

/// Arabic letters, marks, digits and punctuation: an item with any of them is shaped with the
/// craft-fonts Arabic face.
fn is_arabic(c: char) -> bool {
Expand All @@ -334,6 +346,13 @@ pub fn first_undrawable(t: &AddedText) -> Option<char> {
let rest = AddedText { text: t.text.chars().filter(|c| !is_thai(*c)).collect(), ..t.clone() };
return first_undrawable(&rest);
}
if t.text.chars().any(is_hangul) {
if let Some(c) = t.text.chars().find(|c| is_hangul(*c) && !pdfcraft_fonts::hangul_has(*c)) {
return Some(c);
}
let rest = AddedText { text: t.text.chars().filter(|c| !is_hangul(*c)).collect(), ..t.clone() };
return first_undrawable(&rest);
}
// Arabic or Hebrew that nothing can draw: there's no face, or the face lacks it.
if let Some(c) =
t.text.chars().find(|c| (shaped_arabic(*c) && !pdfcraft_fonts::arabic_has(*c)) || (is_hebrew(*c) && !pdfcraft_fonts::hebrew_has(*c)))
Expand Down Expand Up @@ -418,6 +437,8 @@ fn shaped_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec<Piece>, f6
let after_hebrew = segments.last().is_some_and(|(kind, _)| *kind == Some(ShapedScript::Hebrew));
let script = if is_thai(c) {
Some(ShapedScript::Thai)
} else if is_hangul(c) {
Some(ShapedScript::Hangul)
} else if is_hebrew(c) || (run_rtl && after_hebrew && !c.is_alphanumeric() && pdfcraft_fonts::hebrew_has(c)) {
Some(ShapedScript::Hebrew)
} else if is_arabic(c) || (run_rtl && !c.is_alphanumeric() && pdfcraft_fonts::arabic_has(c)) {
Expand All @@ -439,6 +460,7 @@ fn shaped_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec<Piece>, f6
ShapedScript::Arabic => shape_arabic(&s, run_rtl).map_err(|e| arabic_error(e, &s))?,
ShapedScript::Hebrew => shape_hebrew(&s, run_rtl).map_err(|e| hebrew_error(e, &s))?,
ShapedScript::Thai => shape_thai(&s).map_err(|e| thai_error(e, &s))?,
ShapedScript::Hangul => shape_hangul(&s).map_err(|e| hangul_error(e, &s))?,
};
Piece::Shaped(shaped)
} else if run_rtl {
Expand Down Expand Up @@ -494,6 +516,16 @@ fn thai_error(e: GlyphError, text: &str) -> EditError {
})
}

fn hangul_error(e: GlyphError, text: &str) -> EditError {
EditError::Invalid(match e {
GlyphError::NoFont => "Korean text needs PdfCraft's Hangul font (Nanum Gothic), which this build doesn't include \
(to build it in, set CRAFT_FONTS_DIR to a craft-fonts checkout that has a Hang face)"
.into(),
GlyphError::Missing => format!("the Hangul font can't show \"{text}\""),
GlyphError::TooComplex => "the Hangul glyphs are too complex; use a shorter text box".into(),
})
}

/// The box a text item occupies (height from its lines).
pub fn text_rect(t: &AddedText) -> [f64; 4] {
let r = norm(t.rect);
Expand Down Expand Up @@ -565,6 +597,7 @@ fn draw_laid_shaped(
ShapedScript::Arabic => arabic_error(e, &c.text),
ShapedScript::Hebrew => hebrew_error(e, &c.text),
ShapedScript::Thai => thai_error(e, &c.text),
ShapedScript::Hangul => hangul_error(e, &c.text),
})?);
}
}
Expand Down Expand Up @@ -705,6 +738,7 @@ fn type3_shaped(doc: &mut Document, clusters: &[&ShapedCluster], outlines: &Hash
ShapedScript::Arabic => "Arabic",
ShapedScript::Hebrew => "Hebrew",
ShapedScript::Thai => "Thai",
ShapedScript::Hangul => "Hangul",
},
|f| f.family,
),
Expand Down Expand Up @@ -768,6 +802,7 @@ fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict, existing:
fonts.set(name.clone().into_bytes(), Object::Dict(font));
let face_or_new = |has: fn(char) -> bool, face: bool| t.text.chars().any(has) && (face || !existing);
if t.text.chars().any(is_thai)
|| t.text.chars().any(is_hangul)
|| face_or_new(is_arabic, pdfcraft_fonts::document_arabic_font().is_some())
|| face_or_new(is_hebrew, pdfcraft_fonts::document_hebrew_font(false).is_some())
{
Expand Down Expand Up @@ -1002,13 +1037,20 @@ fn validate(c: &Content) -> Result<(), EditError> {
if t.text.chars().any(is_thai) && pdfcraft_fonts::document_thai_font().is_none() {
return Err(thai_error(GlyphError::NoFont, &t.text));
}
if t.text.chars().any(is_hangul) && pdfcraft_fonts::document_korean_font(false).is_none() {
return Err(hangul_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)))
line.chars().find(|c| {
!(shaped_arabic(*c)
|| is_thai(*c) && pdfcraft_fonts::thai_has(*c)
|| is_hangul(*c) && pdfcraft_fonts::hangul_has(*c)
|| pdfcraft_fonts::win_ansi_encodable(*c))
})
})
} else {
first_undrawable(t)
Expand Down
72 changes: 72 additions & 0 deletions crates/edit/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2813,6 +2813,78 @@ fn added_thai_text_wraps_clusters_and_survives_reopen() {
assert!(arabic_font_names(&doc, 0).is_empty());
}

#[test]
fn added_korean_text_wraps_at_spaces_and_survives_reopen() {
let mut doc = fixture();
let t = AddedText {
rect: [20.0, 100.0, 250.0, 300.0], text: "안녕하세요 PdfCraft 한국어 텍스트 123".into(), size: 16.0, ..AddedText::default()
};
if pdfcraft_fonts::document_korean_font(false).is_none() {
assert_eq!(first_undrawable(&t), Some('안'));
let before = list_added(&doc);
let err = add_content(&mut doc, 0, &Content::Text(t)).unwrap_err().to_string();
assert!(err.contains("Korean") && err.contains("CRAFT_FONTS_DIR"), "{err}");
assert_eq!(list_added(&doc), before);
return;
}
assert_eq!(first_undrawable(&t), None);
add_content(&mut doc, 0, &Content::Text(t.clone())).unwrap();
let reopened = reopen(&doc);
assert_eq!(list_added(&reopened)[0].content, Content::Text(AddedText { rect: super::added::text_rect(&t), ..t.clone() }));
// Wrapping keeps every word whole and loses no character.
let lines = super::added::lines(&AddedText { rect: [0.0, 0.0, 120.0, 200.0], ..t.clone() });
assert!(lines.len() > 1, "{lines:?}");
assert_eq!(lines.join(" "), t.text);
// The saved page text reads back as typed (ActualText and ToUnicode).
let content = String::from_utf8_lossy(&stream_bytes(&reopened, &Object::Ref(list_added(&reopened)[0].obj)).unwrap()).into_owned();
assert!(content.contains("/ActualText"), "{content}");
update_content(&mut doc, 0, 0, &Content::Text(AddedText { text: "수정한 글 한 줄".into(), ..t.clone() })).unwrap();
// A character the face lacks is refused and leaves the item as it was.
let bad = Content::Text(AddedText { text: "한국어 \u{1F600}".into(), ..t });
let before = list_added(&doc);
assert!(update_content(&mut doc, 0, 0, &bad).is_err());
assert_eq!(list_added(&doc), before);
delete_content(&mut doc, 0, 0).unwrap();
assert!(list_added(&doc).is_empty());
}

#[test]
fn replacing_a_line_with_korean_text_uses_the_hangul_face() {
let mut doc = text_page("BT /F1 12 Tf 50 600 Td (Hello) Tj ET");
let before = page_content_bytes(&doc, 0);
let typed = "안녕하세요 PDF 문서 한글 123";
let line = text::replace_line(&mut doc, 0, 0, typed);
if pdfcraft_fonts::document_korean_font(false).is_none() {
let Err(EditError::Invalid(msg)) = line else { panic!("expected a clear error, got {line:?}") };
assert!(msg.contains("Korean fallback font") && msg.contains("CRAFT_FONTS_DIR"), "{msg}");
assert_eq!(page_content_bytes(&doc, 0), before);
eprintln!("built without a craft-fonts Hangul face: the Type 3 path is checked with CRAFT_FONTS_DIR");
return;
}
line.unwrap();
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
assert!(content.contains("/PCKo"), "{content}");
let reopened = reopen(&doc);
assert_eq!(text::text_lines(&reopened, 0).unwrap()[0].text, typed);
// Every distinct character has an outline procedure, so nothing is drawn as a box.
let p = pdfcraft_model::pages(&reopened).swap_remove(0);
let res = reopened.resolve(p.dict.get(b"Resources").unwrap());
let fonts = reopened.resolve(res.as_dict().unwrap().get(b"Font").unwrap());
let font = reopened.resolve(fonts.as_dict().unwrap().get(b"PCKo").unwrap());
let procs = reopened.resolve(font.as_dict().unwrap().get(b"CharProcs").unwrap());
let procs = procs.as_dict().unwrap();
assert_eq!(procs.iter().count(), typed.chars().collect::<std::collections::HashSet<_>>().len());
for (_, glyph) in procs.iter() {
let pdfcraft_cos::Object::Stream(s) = &*reopened.resolve(glyph) else { panic!("glyph stream") };
let path = String::from_utf8(s.decoded().unwrap()).unwrap();
assert!(path.contains(" d1"), "{path}");
}
// A second edit of the same line keeps working and still reads back.
let mut doc = reopened;
text::replace_line(&mut doc, 0, 0, "다시 수정 한글").unwrap();
assert_eq!(text::text_lines(&reopen(&doc), 0).unwrap()[0].text, "다시 수정 한글");
}

#[test]
fn group_moves_preserve_glyph_bytes_and_unselected_text_state() {
for content in [
Expand Down
Loading