fix(edit): break the row on every Unicode row break (#6295)

This commit is contained in:
Oliver H.
2026-11-09 08:54:37 +08:00
committed by GitHub
parent 58c30af312
commit 616f245b04
8 changed files with 131 additions and 27 deletions
+81
View File
@@ -13881,3 +13881,84 @@ fn editing_legacy_thai_keeps_the_embedded_face_weight_size_spacing_and_paint_aft
assert_eq!(page_font_programs(&doc), fonts, "no replacement font program may be added");
}
}
/// The Unicode row breaks are invisible moves, not glyphs. A draft carrying them (text pasted
/// from a browser or Word) breaks the row on both rewrite paths, rather than being refused
/// because no fallback face has an outline for U+2028 (#6272). The paragraph is one wide row, so
/// a break that was dropped would leave the words on one row instead of two. A one-line edit has
/// no next row, so there each break is written as a space.
#[test]
fn unicode_row_breaks_break_the_row_instead_of_needing_a_glyph() {
let red = text::RangeStyle {
range: 0..3,
family: None,
resource: None,
size: None,
color: Some([1.0, 0.0, 0.0]),
underline: None,
char_spacing: None,
scale: None,
};
for separator in ['\u{2028}', '\u{2029}', '\u{0085}', '\u{000B}', '\u{000C}', '\u{001C}', '\u{001D}', '\u{001E}'] {
let draft = format!("one{separator}two");
for style in [
text::BlockStyle::default(),
text::BlockStyle { ranges: vec![red.clone()], ..Default::default() },
text::BlockStyle { ranges: vec![text::RangeStyle { range: 0..draft.len(), ..red.clone() }], ..Default::default() },
text::BlockStyle { bold: Some(true), ..Default::default() },
] {
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello world) Tj ET");
text::rewrite_block(&mut doc, 0, 0, Some(&draft), &style).unwrap_or_else(|e| panic!("U+{:04X}: {e}", u32::from(separator)));
let doc = reopen(&doc);
let lines: Vec<String> = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect();
assert_eq!(lines, ["one", "two"], "U+{:04X}", u32::from(separator));
}
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello world) Tj ET");
text::replace_line(&mut doc, 0, 0, &draft).unwrap_or_else(|e| panic!("U+{:04X}: {e}", u32::from(separator)));
let doc = reopen(&doc);
let lines: Vec<String> = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect();
assert_eq!(lines, ["one two"], "a one-line edit keeps U+{:04X} on its line", u32::from(separator));
}
}
/// Thai text on the styled path has its own coverage checks and row splits. A Unicode row break
/// in a Thai draft breaks the row there too, whether the paragraph keeps its legacy subset, takes
/// the bundled Thai face by choice or is completed by the matching one, instead of being refused
/// for a glyph no face has (#6272).
#[test]
fn thai_paragraphs_break_the_row_on_unicode_row_breaks() {
let rows = |doc: &Document| -> Vec<String> { text::text_lines(&reopen(doc), 0).unwrap().into_iter().map(|l| l.text).collect() };
let thai_face = pdfcraft_fonts::document_thai_font().is_some();
if !thai_face {
eprintln!("thai_paragraphs_break_the_row_on_unicode_row_breaks: built without craft-fonts, so only the legacy subset is checked");
}
for separator in ['\u{2028}', '\u{001C}'] {
let draft = format!("กข{separator}ค");
let mut doc = text_page("BT /F1 16 Tf 50 650 Td <00d6> Tj ET");
let font = legacy_thai_font(&mut doc, "ABCDEF+THSarabunPSK", &legacy_thai_program(), LEGACY_THAI_UNICODE);
doc.set(pdfcraft_cos::ObjRef::new(5, 0), Object::Dict(font));
let style = BlockStyle { width: Some(400.0), ..Default::default() };
text::rewrite_block(&mut doc, 0, 0, Some(&draft), &style).unwrap_or_else(|e| panic!("legacy U+{:04X}: {e}", u32::from(separator)));
assert_eq!(rows(&doc), ["กข", "ค"], "legacy U+{:04X}", u32::from(separator));
if !thai_face {
continue;
}
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello world) Tj ET");
let chosen = BlockStyle { resource: Some("builtin:thai".into()), ..Default::default() };
text::rewrite_block(&mut doc, 0, 0, Some(&draft), &chosen).unwrap_or_else(|e| panic!("chosen U+{:04X}: {e}", u32::from(separator)));
assert_eq!(rows(&doc), ["กข", "ค"], "chosen U+{:04X}", u32::from(separator));
let mut doc = text_page("BT /F1 16 Tf 50 700 Td (A) 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();
text::rewrite_block(&mut doc, 0, 0, Some(&draft), &BlockStyle::default())
.unwrap_or_else(|e| panic!("matching U+{:04X}: {e}", u32::from(separator)));
assert_eq!(rows(&doc), ["กข", "ค"], "matching U+{:04X}", u32::from(separator));
}
}
+1 -1
View File
@@ -36,13 +36,13 @@ mod interp;
mod lines;
mod rewrite;
use blocks::paragraph_separator;
#[cfg(test)]
use blocks::wrap;
pub use blocks::{
BlockKey, BlockStyle, RangeStyle, ReadingBlocks, TextBlock, block_overlapping, clear_block, reading_blocks, reading_blocks_checked,
replace_block, text_blocks,
};
use blocks::{paragraph_separator, row_break};
pub use editor::{TextEditLayout, TextEditorFont, register_text_font, text_edit_layout, text_editor_fonts};
use editor::{builtin_thai_choice, layout_draft};
#[cfg(test)]
+9
View File
@@ -344,6 +344,15 @@ pub(crate) fn paragraph_separator(c: char) -> bool {
c.is_whitespace() && c != '\u{3000}'
}
/// Characters that end a row rather than draw a glyph: the Unicode mandatory breaks (UAX #14
/// class BK: vertical tab, form feed, U+2028 LINE SEPARATOR, U+2029 PARAGRAPH SEPARATOR), the
/// next line (U+0085), the information separators (bidi class B) and CR/LF. Text pasted from a
/// browser or Word carries these, so a rewrite moves to the next row instead of asking a font
/// for an outline no face has (#6272).
pub(crate) fn row_break(c: char) -> bool {
matches!(c, '\n' | '\r' | '\u{000B}' | '\u{000C}' | '\u{0085}' | '\u{001C}'..='\u{001E}' | '\u{2028}' | '\u{2029}')
}
/// Greedy word wrapping to `width` with `advance` giving a string's width. Each `\n` starts a
/// new line. A row may also end inside a word of Chinese or Japanese text ([`crate::linebreak`]);
/// those pieces join without a space. Every white-space character is written as one space, so
+7 -7
View File
@@ -1,4 +1,4 @@
use super::blocks::{BlockStyle, TextBlock, paragraph_separator, wrap};
use super::blocks::{BlockStyle, TextBlock, paragraph_separator, row_break, wrap};
use super::fallback::{
Encoder, MAX_TYPE3_GLYPHS, Type3Fallback, drop_unused_fallback_fonts, needs_type3, no_japanese_font, source_encodes, source_family,
source_program_completes, source_show, source_space, source_width, type3_encode, type3_font,
@@ -42,7 +42,7 @@ fn refuse_clipping_text(lines: &[TextLine], dropped: &std::collections::HashSet<
/// [`replace_line`] with the most unique characters one generated font may hold (tests lower it).
pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usize, text: &str, max_glyphs: usize) -> Result<LineEdit, EditError> {
let text = text.replace(['\n', '\r'], " ");
let text = text.replace(row_break, " ");
let lines = text_lines(doc, page)?;
let target = lines.get(line).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no line {}", page + 1, line + 1)))?;
target.ensure_editable()?;
@@ -240,7 +240,7 @@ fn choose_rewrite_path(
}
let visual_indic = members.iter().any(|line| line.visual_order);
let has_special_text = text.is_some_and(|draft| {
draft.contains(['\n', '\r'])
draft.contains(row_break)
|| pdfcraft_fonts::source_shaping::required(draft)
|| !pdfcraft_fonts::embedding::matching_faces(&block.base_font).is_empty()
});
@@ -259,14 +259,14 @@ fn choose_rewrite_path(
}
/// The paragraph's text for writing back: explicit line breaks stay (#1443), and so does each typed
/// U+0020 space: a run of them is not folded into one (#4199, #3797). `\r\n` and a lone `\r` are
/// breaks too; other white space is written as one space.
/// U+0020 space: a run of them is not folded into one (#4199, #3797). Every [`row_break`] is a break
/// too; other white space is written as one space.
fn paragraph_text(text: &str) -> String {
text.replace("\r\n", "\n")
.chars()
.map(|c| match c {
'\r' => '\n',
c if c != '\n' && paragraph_separator(c) => ' ',
c if row_break(c) => '\n',
c if paragraph_separator(c) => ' ',
c => c,
})
.collect()
+3 -3
View File
@@ -3,9 +3,9 @@
use super::{
BlockStyle, CraftFont, Dict, Document, EditError, HashMap, LineEdit, MAX_TYPE3_GLYPHS, Matrix, Metrics, Object, Op, PdfString, Pieces,
RangeStyle, TextBlock, TextLine, Type3Glyph, builtin_thai_choice, coincident_ops, content_streams, fallback_face, japanese_glyph_from,
needs_type3, no_fallback_font, no_glyph_for, page_dict, paragraph_separator, reveal_ops, rewritten_contents, same_base_font, same_font,
source_encodes, source_family, source_program, source_show, source_space, source_width, splice, strip_subset_tag, type3_faces, type3_glyph,
union, write_type3_glyphs,
needs_type3, no_fallback_font, no_glyph_for, page_dict, paragraph_separator, reveal_ops, rewritten_contents, row_break, same_base_font,
same_font, source_encodes, source_family, source_program, source_show, source_space, source_width, splice, strip_subset_tag, type3_faces,
type3_glyph, union, write_type3_glyphs,
};
#[path = "text_regions/grouping.rs"]
+21 -14
View File
@@ -3,7 +3,7 @@ use super::grouping::{across, is_centred, row_rect};
use super::{
BlockStyle, CraftFont, Dict, Document, EditError, HashMap, LineEdit, MAX_TYPE3_GLYPHS, Matrix, Metrics, Object, Op, PdfString, Pieces,
RangeStyle, TextBlock, TextLine, Type3Glyph, builtin_thai_choice, coincident_ops, content_streams, fallback_face, japanese_glyph_from,
needs_type3, no_fallback_font, no_glyph_for, page_dict, reveal_ops, rewritten_contents, source_encodes, source_family, source_program,
needs_type3, no_fallback_font, no_glyph_for, page_dict, reveal_ops, rewritten_contents, row_break, source_encodes, source_family, source_program,
source_show, source_space, source_width, splice, type3_faces, type3_glyph, write_type3_glyphs,
};
@@ -181,8 +181,8 @@ impl CharacterPreparation<'_> {
"the bundled Thai font ({thai_choice}) is not in this build, which has no craft-fonts Thai face; load a complete Thai font or choose one the page lists"
))
})?;
if !pdfcraft_fonts::embedding::covers(face.bytes, span) {
let missing = span.chars().find(|c| !pdfcraft_fonts::embedding::covers(face.bytes, c.encode_utf8(&mut [0; 4])));
if !covers_rows(face.bytes, span) {
let missing = span.chars().find(|&c| !row_break(c) && !pdfcraft_fonts::embedding::covers(face.bytes, c.encode_utf8(&mut [0; 4])));
return Err(EditError::Invalid(match missing {
Some(c) => format!("{} has no glyph for {c} (U+{:04X}); choose a font that covers the text", face.family, u32::from(c)),
None => format!("{} cannot show every character in this selection; choose a font that covers the text", face.family),
@@ -251,12 +251,13 @@ impl CharacterPreparation<'_> {
};
let start = range.start;
for (offset, ch) in draft.get(range).unwrap_or_default().char_indices() {
if ch == '\n' {
chars.push((ch, index, source.font.clone()));
continue;
}
if ch == '\r' {
let crlf = start.checked_add(offset).and_then(|at| draft.get(at.saturating_add(1)..)).is_some_and(|rest| rest.starts_with('\n'));
// A row break moves to the next row instead of asking a font for a glyph. The CR
// of a CRLF stays a `\r` the rows skip, so the pair is one break; every other break
// is written as `\n`. Either way it keeps its place among the draft's characters,
// which rows are counted in.
if row_break(ch) {
let crlf = ch == '\r'
&& start.checked_add(offset).and_then(|at| draft.get(at.saturating_add(1)..)).is_some_and(|rest| rest.starts_with('\n'));
chars.push((if crlf { '\r' } else { '\n' }, index, source.font.clone()));
continue;
}
@@ -923,7 +924,7 @@ fn legacy_thai_font(
if !has_thai_source_face(cache, &family) {
return Ok(None);
}
let rows = || span.split(['\n', '\r']).filter(|row| !row.is_empty());
let rows = || span.split(row_break).filter(|row| !row.is_empty());
for row in rows() {
if let Err(error) = shape_existing_thai(cache, &source.font, row) {
// Not in the face's existing codes. A complete program of the face that shapes every
@@ -1097,7 +1098,7 @@ fn matching_thai_outline(doc: &Document, fonts: &Dict, source: &TextLine, span:
}
if !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)
|| !covers_rows(face.bytes, span)
{
return false;
}
@@ -1112,16 +1113,22 @@ fn matching_thai_outline(doc: &Document, fonts: &Dict, source: &TextLine, span:
&& pdfcraft_fonts::pdf::embedded_sfnt(doc, &font).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', '\r']).all(|row| pdfcraft_fonts::source_shaping::shape(&bytes, row).is_ok())
&& covers_rows(&bytes, span)
&& span.split(row_break).all(|row| pdfcraft_fonts::source_shaping::shape(&bytes, row).is_ok())
})
})
}
/// Whether a complete program draws every row of `span`. [`row_break`] characters move to the next
/// row and are never drawn, so they need no glyph (#6272); `embedding::covers` exempts only CR and LF.
fn covers_rows(bytes: &[u8], span: &str) -> bool {
span.split(row_break).all(|row| pdfcraft_fonts::embedding::covers(bytes, row))
}
/// 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']) {
if text.len() > 262_144 || text.contains(row_break) {
return Err(EditError::Invalid("shape one bounded text row at a time".into()));
}
let clusters =
+7
View File
@@ -122,6 +122,13 @@ sign before an amount: the line-start and line-end rules of JIS X 4051, as in Un
breaking (UAX #14). Latin words inside such text are not split. Korean and Thai still break only
at spaces.
Text pasted from a browser or a word processor often carries an invisible break instead of a
newline. Every Unicode row break starts a new row: U+2028 LINE SEPARATOR, U+2029 PARAGRAPH
SEPARATOR, U+0085 NEXT LINE, the vertical tab, the form feed and the information separators
(U+001C to U+001E), alongside the carriage return and the newline. These move the text down a
row rather than being looked up in a font, so a paste no longer fails because no face has an
outline for them. Other white space is still written as one space.
Select text within the editor before changing size, colour, font, underline or spacing to
format that selection. Automation supports up to 1024 disjoint UTF-8 byte ranges with the
same properties; invalid or overlapping ranges fail atomically. Untouched source formatting
File diff suppressed because one or more lines are too long