M7: preserve ideographic spaces in edited paragraphs (#309)

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
midasdf 2026-10-09 02:29:15 +09:00 committed by GitHub
parent 87f635cf65
commit 81a6ca456c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 46 additions and 6 deletions

View File

@ -316,6 +316,26 @@ fn japanese_paragraph_uses_unicode_type3_fallback() {
assert_eq!(text::text_blocks(&reopened, 0).unwrap()[0].text, replacement);
}
#[test]
fn japanese_paragraph_keeps_ideographic_spaces() {
if without_craft_fonts("japanese_paragraph_keeps_ideographic_spaces") {
return;
}
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Original paragraph) Tj ET");
let replacement = " 見本商会  御中 ";
let style = text::BlockStyle { width: Some(400.0), ..Default::default() };
text::rewrite_block(&mut doc, 0, 0, Some(replacement), &style).unwrap();
let reopened = reopen(&doc);
let lines = text::text_lines(&reopened, 0).unwrap();
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].text, replacement);
assert_eq!(text::text_blocks(&reopened, 0).unwrap()[0].text, replacement);
// Restyling the saved paragraph without new text must also preserve its spacing.
let mut doc = reopened;
text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { size: Some(14.0), ..style }).unwrap();
assert_eq!(text::text_lines(&reopen(&doc), 0).unwrap()[0].text, replacement);
}
/// Without craft-fonts there is no Japanese face: editing in Japanese is a clear error that
/// leaves the page untouched, never a panic; Latin edits work as before.
#[test]

View File

@ -800,13 +800,13 @@ fn group_blocks(lines: &[TextLine]) -> Vec<TextBlock> {
} else {
b.text.push(' ');
}
b.text.push_str(l.text.trim());
b.text.push_str(l.text.trim_matches(paragraph_separator));
b.rect = [b.rect[0].min(l.rect[0]), b.rect[1].min(l.rect[1]), b.rect[2].max(l.rect[2]), b.rect[3].max(l.rect[3])];
b.lines.push(i);
} else {
gap = None;
blocks.push(TextBlock {
text: l.text.trim().to_string(),
text: l.text.trim_matches(paragraph_separator).to_string(),
rect: l.rect,
base_font: l.base_font.clone(),
size: l.size,
@ -820,11 +820,17 @@ fn group_blocks(lines: &[TextLine]) -> Vec<TextBlock> {
blocks
}
// An ideographic space carries intentional Japanese spacing, including paragraph indentation.
// Keep other whitespace normalization unchanged.
fn paragraph_separator(c: char) -> bool {
c.is_whitespace() && c != '\u{3000}'
}
/// Greedy word wrapping to `width` with `advance` giving a string's width.
fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec<String> {
let mut out = Vec::new();
let mut line = String::new();
for word in text.split_whitespace() {
for word in text.split(paragraph_separator).filter(|word| !word.is_empty()) {
let candidate = if line.is_empty() { word.to_string() } else { format!("{line} {word}") };
if !line.is_empty() && advance(&candidate) > width {
out.push(std::mem::take(&mut line));
@ -882,7 +888,7 @@ pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option
let streams = content_streams(doc, &p.dict);
let (stream_obj, data) = streams.get(first.stream).cloned().ok_or_else(|| EditError::Invalid("the page's content changed".into()))?;
let ops = parse(&data).ops;
let text = text.unwrap_or(&b.text).split_whitespace().collect::<Vec<_>>().join(" ");
let text = text.unwrap_or(&b.text).split(paragraph_separator).filter(|word| !word.is_empty()).collect::<Vec<_>>().join(" ");
let o = &first.origin;
let (font_name, old_size) = o.state.font.clone().ok_or_else(|| EditError::Invalid("the paragraph has no font".into()))?;
let k = o.k.max(1e-6);
@ -1105,3 +1111,17 @@ pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option
})?;
Ok(LineEdit { substituted })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn paragraph_wrapping_keeps_ideographic_spaces() {
let width = |s: &str| s.chars().count() as f64;
assert_eq!(wrap("A  B C", 4.0, width), ["A  B", "C"]);
assert_eq!(wrap("A\u{a0}B C", 3.0, width), ["A B", "C"]);
assert_eq!(wrap("A\u{2028}B C", 3.0, width), ["A B", "C"]);
assert_eq!(wrap(" \tA B\r\nC ", 3.0, width), ["A B", "C"]);
}
}

View File

@ -1806,8 +1806,8 @@ milestone = "M7"
status = "shipped"
commands = ["edit.edit_text"]
tools = ["text_edit"]
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"]
notes = "edited paragraphs are rewrapped to the box width with the font's own advance widths and the original line spacing, starting at the original text matrix; text state and fill colour restored after; a single line grows to the right margin"
evidence = ["crates/edit/src/tests.rs::japanese_paragraph_keeps_ideographic_spaces", "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"]
notes = "edited paragraphs are rewrapped to the box width with the font's own advance widths and the original line spacing, starting at the original text matrix; text state and fill colour restored after; a single line grows to the right margin; U+3000 ideographic spaces are preserved (including paragraph edges and repeated spaces); Japanese font-style fallback remains limited (#184)"
[[feature]]
id = "edit.text-move-resize"