mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-13 11:55:14 +00:00
fix(edit): break the row on every Unicode row break (#6295)
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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"]
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user