mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-31 13:37:35 -06:00
M7: rejoin a wrapped line when the box is widened (#2517)
A newline that was typed stays. A wrap is marked so a later width change can reflow it, and a short unmarked line stays broken.
This commit is contained in:
@@ -9,3 +9,4 @@ plan/
|
||||
/assets/models
|
||||
# A craft-fonts checkout used as the optional CRAFT_FONTS_DIR build input (CI and releases put it here).
|
||||
/craft-fonts
|
||||
.claude/
|
||||
|
||||
+74
-14
@@ -2807,9 +2807,8 @@ fn a_blank_line_keeps_the_next_paragraph() {
|
||||
// The second row is left in the source stream. A marker around only the rewritten row
|
||||
// would split the paragraph; either there is no marker, or it covers both rows and not the next paragraph.
|
||||
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
|
||||
if let Some(start) = content.find("/P BMC") {
|
||||
let end = start + content[start..].find("EMC").unwrap_or(content.len() - start);
|
||||
let marked = &content[start..end];
|
||||
if content.contains("/P BMC") {
|
||||
let marked = region_through_matching_close(&content, "/P BMC");
|
||||
assert!(marked.contains("edited row") && marked.contains("second row"), "marker split the paragraph: {content}");
|
||||
assert!(!marked.contains("Second paragraph"), "marker swallowed the next paragraph: {content}");
|
||||
}
|
||||
@@ -2885,8 +2884,7 @@ fn growing_into_a_blank_line_keeps_the_neighbour_after_save() {
|
||||
let texts = |doc: &Document| text::text_blocks(doc, 0).unwrap().into_iter().map(|b| b.text).collect::<Vec<_>>();
|
||||
assert_eq!(texts(&doc), ["One Two Three", "Neighbour"]);
|
||||
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
|
||||
let marked = content.find("/P BMC").and_then(|start| content[start..].find("EMC").map(|end| content[start..start + end].to_owned()));
|
||||
let marked = marked.unwrap_or_else(|| panic!("a full rewrite did not mark its rows: {content}"));
|
||||
let marked = region_through_matching_close(&content, "/P BMC");
|
||||
for row in ["(One)", "(Two)", "(Three)"] {
|
||||
assert!(marked.contains(row), "{row} is outside the marker: {content}");
|
||||
}
|
||||
@@ -3109,20 +3107,42 @@ fn page_content(doc: &Document) -> String {
|
||||
|
||||
/// `open` through the next `EMC` contains both strings, and no `EMC` sits inside a text object.
|
||||
fn assert_marker_wraps(content: &str, open: &str, first: &str, second: &str) {
|
||||
let start = content.find(open).unwrap_or_else(|| panic!("missing {open}: {content}"));
|
||||
let end = content[start..].find("EMC").map(|at| start + at).unwrap_or_else(|| panic!("missing EMC after {open}: {content}"));
|
||||
let marked = &content[start..end];
|
||||
let marked = region_through_matching_close(content, open);
|
||||
assert!(marked.contains(first) && marked.contains(second), "{open} closed too early: {content}");
|
||||
let mut text_objects = 0i32;
|
||||
assert!(!marker_crosses_a_text_object(content), "a marker opened outside a text object and closed inside it: {content}");
|
||||
}
|
||||
|
||||
/// A marker may sit inside one text object. It may not open outside one and close inside it.
|
||||
fn marker_crosses_a_text_object(content: &str) -> bool {
|
||||
let mut text = 0i32;
|
||||
let mut opened_outside = 0i32;
|
||||
let mut opened_inside = 0i32;
|
||||
for token in content.split_whitespace() {
|
||||
match token {
|
||||
"BT" => text_objects += 1,
|
||||
"ET" => text_objects -= 1,
|
||||
"EMC" => assert_eq!(text_objects, 0, "EMC inside a text object: {content}"),
|
||||
"BT" => text += 1,
|
||||
"ET" => text -= 1,
|
||||
"BMC" | "BDC" => {
|
||||
if text == 0 {
|
||||
opened_outside += 1;
|
||||
} else {
|
||||
opened_inside += 1;
|
||||
}
|
||||
}
|
||||
"EMC" => {
|
||||
if text > 0 {
|
||||
if opened_inside > 0 {
|
||||
opened_inside -= 1;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
} else if opened_outside > 0 {
|
||||
opened_outside -= 1;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert_eq!(text_objects, 0, "a text object was left open: {content}");
|
||||
text != 0 || opened_outside != 0 || opened_inside != 0
|
||||
}
|
||||
|
||||
/// A blank line is one extra leading, so the fourth row of a grown paragraph sits on the
|
||||
@@ -3149,6 +3169,45 @@ fn resizing_a_grown_paragraph_keeps_the_neighbour() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Narrowing wraps a line. Widening it again rejoins that wrap. A newline that was typed stays.
|
||||
#[test]
|
||||
fn a_wrapped_line_rejoins_when_widened_and_a_typed_break_stays() {
|
||||
let mut doc = text_page("BT /F1 24 Tf 20 150 Td (Page 1) Tj ET");
|
||||
text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { width: Some(45.0), ..Default::default() }).unwrap();
|
||||
let block = text::text_blocks(&doc, 0).unwrap().into_iter().next().unwrap();
|
||||
assert!(block.editor_text.contains('\n'), "the narrow box did not wrap: {}", block.editor_text);
|
||||
assert_eq!(block.reflow_text, "Page 1", "a wrap was kept as a hard break: {}", block.reflow_text);
|
||||
text::rewrite_block(&mut doc, 0, 0, Some(&block.reflow_text), &text::BlockStyle { width: Some(200.0), ..Default::default() }).unwrap();
|
||||
let lines: Vec<_> = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect();
|
||||
assert_eq!(lines, ["Page 1"]);
|
||||
|
||||
let mut typed = text_page("BT /F1 12 Tf 72 700 Td (One) Tj ET");
|
||||
text::replace_block(&mut typed, 0, 0, "One\nTwo\nThree\nfour").unwrap();
|
||||
let block = text::text_blocks(&typed, 0).unwrap().into_iter().next().unwrap();
|
||||
assert_eq!(block.reflow_text, "One\nTwo\nThree\nfour", "a typed break was rejoined: {}", block.reflow_text);
|
||||
let width = (block.rect[2] - block.rect[0]) * 6.0;
|
||||
text::rewrite_block(&mut typed, 0, 0, Some(&block.reflow_text), &text::BlockStyle { width: Some(width.max(180.0)), ..Default::default() })
|
||||
.unwrap();
|
||||
assert_eq!(text::text_blocks(&typed, 0).unwrap()[0].editor_text, "One\nTwo\nThree\nfour");
|
||||
}
|
||||
|
||||
/// A file that did not mark its breaks still rejoins a line that fills the measure, and keeps a
|
||||
/// short line that had room.
|
||||
#[test]
|
||||
fn an_unmarked_full_line_rejoins_and_a_short_break_stays() {
|
||||
let long = "BT /F1 12 Tf 1 0 0 1 72 700 Tm (This sentence is long enough to fill a measure.) Tj ET \
|
||||
BT /F1 12 Tf 1 0 0 1 72 685.6 Tm (Tail) Tj ET";
|
||||
let doc = text_page(long);
|
||||
let block = text::text_blocks(&doc, 0).unwrap().into_iter().next().unwrap();
|
||||
assert_eq!(block.rows.len(), 2, "{}", block.editor_text);
|
||||
assert_eq!(block.reflow_text, "This sentence is long enough to fill a measure. Tail", "{}", block.reflow_text);
|
||||
|
||||
let short = "BT /F1 12 Tf 1 0 0 1 72 700 Tm (One) Tj ET BT /F1 12 Tf 1 0 0 1 72 685.6 Tm (Two) Tj ET";
|
||||
let doc = text_page(short);
|
||||
let block = text::text_blocks(&doc, 0).unwrap().into_iter().next().unwrap();
|
||||
assert_eq!(block.reflow_text, "One\nTwo", "{}", block.reflow_text);
|
||||
}
|
||||
|
||||
/// The letter the screenshots edit. Widening example 1, or adding a row there, must leave the
|
||||
/// heading above it and both paragraphs under example 2 as they were.
|
||||
fn letter_page() -> &'static str {
|
||||
@@ -3181,7 +3240,8 @@ fn widening_example_one_does_not_swallow_the_heading_or_split_the_next_paragraph
|
||||
text::replace_block(&mut doc, 0, idx, "One\nTwo\nThree\nfour").unwrap();
|
||||
let blocks = text::text_blocks(&doc, 0).unwrap();
|
||||
let idx = blocks.iter().position(|b| b.text.contains("four") || b.editor_text.contains("four")).unwrap();
|
||||
let shown = blocks[idx].editor_text.clone();
|
||||
assert_eq!(blocks[idx].reflow_text, "One\nTwo\nThree\nfour", "a typed break was treated as a wrap: {}", blocks[idx].reflow_text);
|
||||
let shown = blocks[idx].reflow_text.clone();
|
||||
let width = blocks[idx].rect[2] - blocks[idx].rect[0];
|
||||
text::rewrite_block(&mut doc, 0, idx, Some(&shown), &text::BlockStyle { width: Some((width * 6.0).max(180.0)), ..Default::default() }).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
|
||||
+30
-17
@@ -118,6 +118,10 @@ pub struct TextLine {
|
||||
/// `ink`, and whether joining put a space before it. Empty when the line's text was put
|
||||
/// into another order (see [`TextLine::runs`]).
|
||||
runs: Vec<(String, usize, bool)>,
|
||||
/// This line continues the previous one because a rewrite wrapped it (`/PCSoft`).
|
||||
soft_break: bool,
|
||||
/// This line starts after a break a rewrite kept on purpose (`/PCHard`).
|
||||
hard_break: bool,
|
||||
}
|
||||
|
||||
impl TextLine {
|
||||
@@ -154,6 +158,7 @@ mod tests {
|
||||
lines: Vec::new(),
|
||||
rows: Vec::new(),
|
||||
editor_text: text.into(),
|
||||
reflow_text: text.into(),
|
||||
spans: Vec::new(),
|
||||
invisible: false,
|
||||
}
|
||||
@@ -204,40 +209,48 @@ mod tests {
|
||||
assert!(!same_base_font("Arial", ""));
|
||||
}
|
||||
|
||||
fn wrapped(text: &str, width: f64) -> Vec<String> {
|
||||
wrap(text, width, |s| s.chars().count() as f64).into_iter().map(|(line, _)| line).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_wrapping_keeps_ideographic_spaces() {
|
||||
assert_eq!(wrapped("A B C", 4.0), ["A B", "C"]);
|
||||
assert_eq!(wrapped("A\u{a0}B C", 3.0), ["A B", "C"]);
|
||||
assert_eq!(wrapped("A\u{2028}B C", 3.0), ["A B", "C"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrap_is_marked_soft_and_a_newline_is_not() {
|
||||
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("Page 1", 5.0, width), [("Page".into(), false), ("1".into(), true)]);
|
||||
assert_eq!(wrap("One\nTwo", 10.0, width), [("One".into(), false), ("Two".into(), false)]);
|
||||
}
|
||||
|
||||
/// #4199: a run of spaces stays a run, before, between and after words; the spaces where a
|
||||
/// row wraps go, and each other white-space character is one space.
|
||||
#[test]
|
||||
fn paragraph_wrapping_keeps_every_space_typed() {
|
||||
let width = |s: &str| s.chars().count() as f64;
|
||||
assert_eq!(wrap("a b c", 20.0, width), ["a b c"]);
|
||||
assert_eq!(wrap(" a b ", 20.0, width), [" a b "]);
|
||||
assert_eq!(wrap("a\t\tb", 20.0, width), ["a b"]);
|
||||
assert_eq!(wrap("aa bb cc", 6.0, width), ["aa bb", "cc"]);
|
||||
assert_eq!(wrapped("a b c", 20.0), ["a b c"]);
|
||||
assert_eq!(wrapped(" a b ", 20.0), [" a b "]);
|
||||
assert_eq!(wrapped("a\t\tb", 20.0), ["a b"]);
|
||||
assert_eq!(wrapped("aa bb cc", 6.0), ["aa bb", "cc"]);
|
||||
// An indent alone on a row doesn't wrap before its first word.
|
||||
assert_eq!(wrap(" abc", 3.0, width), [" abc"]);
|
||||
assert_eq!(wrapped(" abc", 3.0), [" abc"]);
|
||||
// The rewrite turns `\r\n` and `\r` into `\n` before wrapping.
|
||||
assert_eq!(wrap(" \tA B\nC ", 3.0, width), [" A", "B", "C "]);
|
||||
assert_eq!(wrap(" ", 3.0, width), [" "]);
|
||||
assert_eq!(wrap("", 3.0, width), [""]);
|
||||
assert_eq!(wrapped(" \tA B\nC ", 3.0), [" A", "B", "C "]);
|
||||
assert_eq!(wrapped(" ", 3.0), [" "]);
|
||||
assert_eq!(wrapped("", 3.0), [""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn japanese_text_wraps_between_characters_but_not_before_closing_marks() {
|
||||
let width = |s: &str| s.chars().count() as f64;
|
||||
assert_eq!(wrap("日本語の文章です。", 3.0, width), ["日本語", "の文章", "です。"]);
|
||||
assert_eq!(wrapped("日本語の文章です。", 3.0), ["日本語", "の文章", "です。"]);
|
||||
// The comma can't begin a row, so the character before it moves down with it.
|
||||
assert_eq!(wrap("あいう、えお", 3.0, width), ["あい", "う、え", "お"]);
|
||||
assert_eq!(wrapped("あいう、えお", 3.0), ["あい", "う、え", "お"]);
|
||||
// Pieces of one word join without a space; separate words keep theirs.
|
||||
assert_eq!(wrap("Edit PDFを編集", 6.0, width), ["Edit", "PDFを編集"]);
|
||||
assert_eq!(wrap("一行目 二行目", 4.0, width), ["一行目 ", "二行目"]);
|
||||
assert_eq!(wrapped("Edit PDFを編集", 6.0), ["Edit", "PDFを編集"]);
|
||||
assert_eq!(wrapped("一行目 二行目", 4.0), ["一行目 ", "二行目"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -25,6 +25,9 @@ pub struct TextBlock {
|
||||
pub rows: Vec<Vec<usize>>,
|
||||
/// Text for the inline editor, with a newline only between visual rows.
|
||||
pub editor_text: String,
|
||||
/// What a width change reflows. A wrap rejoins with a space; a break that was typed, or a
|
||||
/// short line that still had room for the next word, stays a newline.
|
||||
pub reflow_text: String,
|
||||
/// Byte ranges in `editor_text` and their source line indexes.
|
||||
pub spans: Vec<(std::ops::Range<usize>, usize)>,
|
||||
/// Every line is invisible text (rendering mode 3): an OCR layer over a scanned image, whose
|
||||
@@ -337,11 +340,13 @@ pub(crate) fn paragraph_separator(c: char) -> bool {
|
||||
/// 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
|
||||
/// a run of spaces typed between, before or after words stays (#4199); only the spaces where a
|
||||
/// row wraps are dropped.
|
||||
pub(super) fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec<String> {
|
||||
/// row wraps are dropped. The bool is true when the line continues the previous one because the
|
||||
/// next word did not fit, rather than because of a newline.
|
||||
pub(super) fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec<(String, bool)> {
|
||||
let mut out = Vec::new();
|
||||
for row in text.split('\n') {
|
||||
let mut line = String::new();
|
||||
let mut soft = false;
|
||||
let start = out.len();
|
||||
// The separators read since the last word: each one boundary between `split` pieces.
|
||||
let mut gap = 0usize;
|
||||
@@ -357,7 +362,8 @@ pub(super) fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec
|
||||
let candidate = if i == 0 { format!("{line}{spaces}{piece}") } else { format!("{line}{piece}") };
|
||||
// A row holding only the spaces that indent it doesn't wrap before its first word.
|
||||
if line.chars().any(|c| c != ' ') && advance(&candidate) > width {
|
||||
out.push(std::mem::take(&mut line));
|
||||
out.push((std::mem::take(&mut line), soft));
|
||||
soft = true;
|
||||
line = piece.to_string();
|
||||
} else {
|
||||
line = candidate;
|
||||
@@ -366,7 +372,7 @@ pub(super) fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec
|
||||
}
|
||||
line.push_str(&" ".repeat(gap));
|
||||
if !line.is_empty() || out.len() == start {
|
||||
out.push(line);
|
||||
out.push((line, soft));
|
||||
}
|
||||
}
|
||||
out
|
||||
|
||||
@@ -301,6 +301,9 @@ pub(super) struct Shown {
|
||||
pub(super) paint: Paint,
|
||||
pub(super) logical_order: bool,
|
||||
pub(super) actual_text: bool,
|
||||
/// Set when a `/PCSoft` or `/PCHard` marker was seen since the previous text line.
|
||||
pub(super) soft_break: bool,
|
||||
pub(super) hard_break: bool,
|
||||
}
|
||||
|
||||
impl Shown {
|
||||
@@ -549,6 +552,8 @@ struct TextRunState<'a> {
|
||||
marked: &'a MarkedContent,
|
||||
carry: &'a mut Carry,
|
||||
out: &'a mut Vec<Shown>,
|
||||
soft_break: bool,
|
||||
hard_break: bool,
|
||||
}
|
||||
|
||||
fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), EditError> {
|
||||
@@ -745,6 +750,8 @@ fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), Edit
|
||||
paint: Paint { layers: marked.layers.iter().flatten().cloned().collect(), gs: ts.gs.clone() },
|
||||
logical_order: false,
|
||||
actual_text: false,
|
||||
soft_break: state.soft_break,
|
||||
hard_break: state.hard_break,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
@@ -769,11 +776,22 @@ pub(super) fn interpret(
|
||||
let mut bt = 0usize;
|
||||
let mut marked =
|
||||
MarkedContent { regions: std::mem::take(&mut carry.marked), layers: std::mem::take(&mut carry.layers), ..MarkedContent::default() };
|
||||
// A rewrite names a wrap (`/PCSoft`) or a break it kept (`/PCHard`) in an empty marker
|
||||
// before the next line. Neither name is a paragraph region, so it does not change scope.
|
||||
let mut soft_break = false;
|
||||
let mut hard_break = false;
|
||||
for (i, op) in ops.iter().enumerate() {
|
||||
match op.op.as_slice() {
|
||||
// No depth cap: every `EMC` pops, so a capped push would pair it with the enclosing
|
||||
// sequence (#3573). Both stacks stay bounded by the number of operators.
|
||||
b"BMC" | b"BDC" => {
|
||||
if op.name(0) == Some(b"PCSoft") {
|
||||
soft_break = true;
|
||||
hard_break = false;
|
||||
} else if op.name(0) == Some(b"PCHard") {
|
||||
hard_break = true;
|
||||
soft_break = false;
|
||||
}
|
||||
marked.begin(doc, op, i, logical_text, out.len());
|
||||
}
|
||||
b"EMC" => {
|
||||
@@ -878,8 +896,26 @@ pub(super) fn interpret(
|
||||
tm = tlm;
|
||||
}
|
||||
b"Tj" | b"TJ" | b"'" | b"\"" => {
|
||||
let mut state = TextRunState { ts: &mut ts, tm: &mut tm, tlm: &mut tlm, at_bt: &at_bt, bt, marked: &marked, carry, out: &mut out };
|
||||
let before = out.len();
|
||||
let mut state = TextRunState {
|
||||
ts: &mut ts,
|
||||
tm: &mut tm,
|
||||
tlm: &mut tlm,
|
||||
at_bt: &at_bt,
|
||||
bt,
|
||||
marked: &marked,
|
||||
carry,
|
||||
out: &mut out,
|
||||
soft_break,
|
||||
hard_break,
|
||||
};
|
||||
show_text(op, i, &mut state)?;
|
||||
// A numeric-only TJ adjustment shows no glyphs and pushes nothing: the break
|
||||
// belongs to whichever text-showing operator comes next.
|
||||
if out.len() > before {
|
||||
soft_break = false;
|
||||
hard_break = false;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -280,6 +280,8 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool) ->
|
||||
clips: s.state.render_mode >= 4,
|
||||
unreadable_mode: s.state.unreadable.as_deref().map(str::to_owned),
|
||||
runs: vec![(s.text.clone(), s.ink.len(), false)],
|
||||
soft_break: s.soft_break,
|
||||
hard_break: s.hard_break,
|
||||
ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(),
|
||||
origin: Origin {
|
||||
tm: s.tm.0,
|
||||
@@ -394,6 +396,8 @@ fn add_shown(lines: &mut Vec<TextLine>, last: &mut Option<Pen>, s: Shown, stream
|
||||
clips: s.state.render_mode >= 4,
|
||||
unreadable_mode: s.state.unreadable.as_deref().map(str::to_owned),
|
||||
runs: vec![(s.text.clone(), s.ink.len(), false)],
|
||||
soft_break: s.soft_break,
|
||||
hard_break: s.hard_break,
|
||||
ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(),
|
||||
origin: Origin {
|
||||
tm: s.tm.0,
|
||||
|
||||
@@ -579,13 +579,28 @@ impl LineWriter<'_> {
|
||||
}
|
||||
|
||||
/// Writes one wrapped line, returning its underline (x0, x1, y) in text space.
|
||||
fn write_line(&self, index: usize, line: &str, last: bool, cursor: &mut LineCursor, ops: &mut Vec<Op>) -> Result<(f64, f64, f64), EditError> {
|
||||
fn write_line(
|
||||
&self,
|
||||
index: usize,
|
||||
line: &str,
|
||||
soft: bool,
|
||||
last: bool,
|
||||
cursor: &mut LineCursor,
|
||||
ops: &mut Vec<Op>,
|
||||
) -> Result<(f64, f64, f64), EditError> {
|
||||
let n = pdfcraft_content::num;
|
||||
let body = line.trim_matches(' ');
|
||||
let interior = body.matches(' ').count();
|
||||
let tw = self.stretch(body, last);
|
||||
let (shown, visible) = self.shown(line, body, tw);
|
||||
let dx = if tw > 0.0 { 0.0 } else { self.alignment.offset(visible) };
|
||||
if index > 0 {
|
||||
// Inside this text object, so the marker starts and ends here. `/PCSoft` rejoins
|
||||
// when the box widens. `/PCHard` is a newline that was already in the text.
|
||||
let name = if soft { "PCSoft" } else { "PCHard" };
|
||||
ops.push(Op::new("BMC", vec![Object::name(name)]));
|
||||
ops.push(Op::new("EMC", vec![]));
|
||||
}
|
||||
if index > 0 || dx != 0.0 {
|
||||
ops.push(Op::new("Td", vec![n(dx - cursor.x), n(if index > 0 { -self.lead } else { 0.0 })]));
|
||||
}
|
||||
@@ -633,8 +648,12 @@ impl LineWriter<'_> {
|
||||
}
|
||||
|
||||
/// Writes every wrapped line, returning their underlines.
|
||||
fn write_all(&self, wrapped: &[String], cursor: &mut LineCursor, ops: &mut Vec<Op>) -> Result<Vec<(f64, f64, f64)>, EditError> {
|
||||
wrapped.iter().enumerate().map(|(i, line)| self.write_line(i, line, i + 1 == wrapped.len(), cursor, ops)).collect()
|
||||
fn write_all(&self, wrapped: &[(String, bool)], cursor: &mut LineCursor, ops: &mut Vec<Op>) -> Result<Vec<(f64, f64, f64)>, EditError> {
|
||||
wrapped
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (line, soft))| self.write_line(i, line, *soft, i + 1 == wrapped.len(), cursor, ops))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -653,7 +672,7 @@ struct BlockPlan<'a> {
|
||||
underline: bool,
|
||||
}
|
||||
|
||||
fn block_ops(plan: &BlockPlan<'_>, writer: &LineWriter<'_>, wrapped: &[String], show_font: String) -> Result<Vec<Op>, EditError> {
|
||||
fn block_ops(plan: &BlockPlan<'_>, writer: &LineWriter<'_>, wrapped: &[(String, bool)], show_font: String) -> Result<Vec<Op>, EditError> {
|
||||
let n = pdfcraft_content::num;
|
||||
let mut ops = vec![Op::new("q", vec![])];
|
||||
ops.extend(reveal_ops(plan.block, plan.lines, plan.style, plan.emit.ctm)?);
|
||||
|
||||
@@ -299,6 +299,7 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec<TextBlock> {
|
||||
blocks.push(TextBlock {
|
||||
text: String::new(),
|
||||
editor_text: String::new(),
|
||||
reflow_text: String::new(),
|
||||
spans: Vec::new(),
|
||||
rect,
|
||||
base_font: first.base_font.clone(),
|
||||
@@ -346,5 +347,53 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec<TextBlock> {
|
||||
block.rect = union(block.rect, rect);
|
||||
block.rows.push(row.clone());
|
||||
}
|
||||
for block in &mut blocks {
|
||||
block.reflow_text = reflow_text(block, lines);
|
||||
}
|
||||
blocks
|
||||
}
|
||||
|
||||
/// The text a width change should reflow. A `/PCSoft` line is a wrap and rejoins. A `/PCHard`
|
||||
/// line, or a short line that is not marked, keeps its newline. An unmarked line that reaches
|
||||
/// the paragraph's right edge and is wide enough to be a measure rejoins too: that is how a
|
||||
/// file written elsewhere wraps a body line.
|
||||
fn reflow_text(block: &TextBlock, lines: &[TextLine]) -> String {
|
||||
let parts: Vec<&str> = block.editor_text.split('\n').collect();
|
||||
if parts.len() != block.rows.len() {
|
||||
return block.editor_text.clone();
|
||||
}
|
||||
let mut out = String::new();
|
||||
for (index, part) in parts.iter().enumerate() {
|
||||
if index > 0 {
|
||||
if break_is_soft(block, lines, index) {
|
||||
if out.ends_with('-') {
|
||||
out.pop();
|
||||
} else if !out.ends_with(char::is_whitespace) {
|
||||
out.push(' ');
|
||||
}
|
||||
} else {
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
out.push_str(part);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn break_is_soft(block: &TextBlock, lines: &[TextLine], row_index: usize) -> bool {
|
||||
let Some(line) = block.rows.get(row_index).and_then(|row| row.first()).and_then(|i| lines.get(*i)) else { return false };
|
||||
if line.hard_break {
|
||||
return false;
|
||||
}
|
||||
if line.soft_break {
|
||||
return true;
|
||||
}
|
||||
let Some(prev) = block.rows.get(row_index.wrapping_sub(1)).and_then(|row| row_rect(row, lines)) else { return false };
|
||||
let Some(next) = block.rows.get(row_index).and_then(|row| row_rect(row, lines)) else { return false };
|
||||
let size = line.size.max(1.0);
|
||||
let slack = block.rect[2] - prev[2];
|
||||
let width = prev[2] - prev[0];
|
||||
// Short rows define the right edge by their own letters. A body line that fills the
|
||||
// measure leaves almost no room for the next word.
|
||||
slack <= size * 0.75 && width >= size * 8.0 && (next[2] - next[0]) + size * 0.25 > slack
|
||||
}
|
||||
|
||||
@@ -405,9 +405,12 @@ fn wrap_styled_rows(
|
||||
chars: &[StyledChar],
|
||||
width: f64,
|
||||
measure: impl Fn(&[StyledChar]) -> Result<f64, EditError>,
|
||||
) -> Result<(StyledRows, RowRanges), EditError> {
|
||||
) -> Result<(StyledRows, RowRanges, Vec<bool>), EditError> {
|
||||
let mut rows: Vec<Vec<StyledChar>> = Vec::new();
|
||||
let mut row_ranges = Vec::new();
|
||||
// True when this row starts because the previous word did not fit, not because of a newline.
|
||||
let mut row_soft = Vec::new();
|
||||
let mut next_soft = false;
|
||||
let mut row_start = 0;
|
||||
let mut row = Vec::new();
|
||||
let mut row_width = 0.0;
|
||||
@@ -419,6 +422,8 @@ fn wrap_styled_rows(
|
||||
continue;
|
||||
}
|
||||
if item.0 == '\n' {
|
||||
row_soft.push(next_soft);
|
||||
next_soft = false;
|
||||
row_ranges.push(row_start..row_start + row.len());
|
||||
rows.push(std::mem::take(&mut row));
|
||||
row_width = 0.0;
|
||||
@@ -442,6 +447,8 @@ fn wrap_styled_rows(
|
||||
while row.last().is_some_and(|c: &StyledChar| c.0.is_whitespace()) {
|
||||
row.pop();
|
||||
}
|
||||
row_soft.push(next_soft);
|
||||
next_soft = true;
|
||||
row_ranges.push(row_start..row_start + row.len());
|
||||
rows.push(std::mem::take(&mut row));
|
||||
row_width = 0.0;
|
||||
@@ -452,10 +459,11 @@ fn wrap_styled_rows(
|
||||
at = end;
|
||||
}
|
||||
if !row.is_empty() {
|
||||
row_soft.push(next_soft);
|
||||
row_ranges.push(row_start..row_start + row.len());
|
||||
rows.push(row);
|
||||
}
|
||||
Ok((rows, row_ranges))
|
||||
Ok((rows, row_ranges, row_soft))
|
||||
}
|
||||
|
||||
fn apply_range_styles<'a>(
|
||||
@@ -822,7 +830,7 @@ pub(crate) fn rewrite_styled_block(
|
||||
}
|
||||
Ok(width)
|
||||
};
|
||||
let (rows, row_ranges) = wrap_styled_rows(&chars, width, measure)?;
|
||||
let (rows, row_ranges, row_soft) = wrap_styled_rows(&chars, width, measure)?;
|
||||
let streams = content_streams(doc, &p.dict)?;
|
||||
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
|
||||
let ops = pieces.parse().ops;
|
||||
@@ -891,6 +899,14 @@ pub(crate) fn rewrite_styled_block(
|
||||
baseline -= source_leading;
|
||||
continue;
|
||||
}
|
||||
if !row.is_empty() && row_index > 0 {
|
||||
// An empty marker between lines. `/PCSoft` is a wrap and rejoins when the box
|
||||
// widens. `/PCHard` is a newline that was already in the text. Neither is a
|
||||
// paragraph region, so the lines stay in one scope.
|
||||
let name = if row_soft.get(row_index).copied().unwrap_or(false) { "PCSoft" } else { "PCHard" };
|
||||
block_ops.push(Op::new("BMC", vec![Object::name(name)]));
|
||||
block_ops.push(Op::new("EMC", vec![]));
|
||||
}
|
||||
let w = measured.get(row_index).copied().unwrap_or(0.0);
|
||||
let align = style.align.unwrap_or(if centered { crate::added::Align::Center } else { crate::added::Align::Left });
|
||||
let original_rect = block.rows.get(row_index).and_then(|row| row_rect(row, lines)).unwrap_or(block.rect);
|
||||
|
||||
@@ -399,9 +399,10 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::Text
|
||||
let width = (l.rect[2] - l.rect[0]) + (to[2] - to[0]) - (from[2] - from[0]);
|
||||
let shift = to[0] - from[0];
|
||||
let style = pdfcraft_engine::BlockStyle { width: Some(width), offset: (shift != 0.0).then_some([shift, 0.0]), ..Default::default() };
|
||||
// `text` joins rows with spaces. The editor text is what the paragraph actually
|
||||
// is, line breaks included; a width change must not throw those breaks away.
|
||||
Edit::EditTextBlock { page, block: d.block, text: l.editor_text.clone(), style }
|
||||
// `editor_text` has a newline for every visual row, so a wrap would come back as a
|
||||
// hard break and a wider box would not reflow it. `reflow_text` rejoins a wrap
|
||||
// and keeps a newline that was typed.
|
||||
Edit::EditTextBlock { page, block: d.block, text: l.reflow_text.clone(), style }
|
||||
} else {
|
||||
// Translation must keep the selected source operators. Rewriting unchanged text
|
||||
// would also remove unrelated paragraphs that now overlap this one (#594).
|
||||
|
||||
@@ -3738,7 +3738,7 @@ fn the_live_editor_groups_a_repeated_step_and_keeps_the_neighbour() {
|
||||
700.0 - step,
|
||||
700.0 - 2.0 * step
|
||||
);
|
||||
let mut h = open_letter("repeated.pdf", letter_text(&repeated));
|
||||
let mut h = open_letter("repeated.pdf", letter_text(repeated.as_str()));
|
||||
assert!(h.state_mut().execute("edit.edit_text"));
|
||||
h.run_steps(2);
|
||||
click_page(&mut h, 90.0, 700.0 - step + 4.0);
|
||||
@@ -3785,7 +3785,7 @@ fn widening_example_one_keeps_the_heading_and_the_next_paragraph() {
|
||||
BT /F1 14 Tf 72 450 Td (3. Add a row under Alpha. Neighbor stays.) Tj ET \
|
||||
BT /F1 12 Tf 14.4 TL 1 0 0 1 72 410 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET \
|
||||
BT /F1 12 Tf 14.4 TL 1 0 0 1 72 366.8 Tm (Neighbor stays.) Tj ET";
|
||||
let mut h = open_letter("letter.pdf", letter_text(&body));
|
||||
let mut h = open_letter("letter.pdf", letter_text(body));
|
||||
assert!(h.state_mut().execute("edit.edit_text"));
|
||||
h.run_steps(2);
|
||||
click_page(&mut h, 90.0, 690.0);
|
||||
|
||||
@@ -1902,8 +1902,8 @@ milestone = "M7"
|
||||
status = "shipped"
|
||||
commands = ["edit.edit_text"]
|
||||
tools = ["text_edit"]
|
||||
evidence = ["crates/edit/src/tests.rs::paragraphs_move_and_rewrap_to_a_new_width", "crates/ui-egui/tests/it/editing.rs::dragging_a_paragraph_moves_it_and_its_edge_rewraps_it", "crates/ui-egui/tests/it/editing.rs::a_paragraph_rewraps_from_the_outer_half_of_its_edge_handles", "crates/ui-egui/tests/it/editing.rs::an_open_paragraph_moves_by_its_frame_and_by_the_arrow_keys", "crates/ui-egui/tests/it/editing.rs::arrow_keys_nudge_a_selected_image_instead_of_turning_the_page", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools"]
|
||||
notes = "existing paragraphs: drag the box to move it, drag the handle on its left or right edge to rewrap it to a new width (upright pages), including the half of the handle outside the box, which started an area selection instead (#2276); the move is applied in page space through the paragraph's CTM, in its own q … Q; a click still opens it for typing (#40); once open, its frame grabs the box (click to select, drag to move) while the text keeps the caret, and the arrow keys nudge the selected box or image a point, Shift ten points (#1520)"
|
||||
evidence = ["crates/edit/src/tests.rs::paragraphs_move_and_rewrap_to_a_new_width", "crates/edit/src/tests.rs::a_wrapped_line_rejoins_when_widened_and_a_typed_break_stays", "crates/edit/src/tests.rs::an_unmarked_full_line_rejoins_and_a_short_break_stays", "crates/ui-egui/tests/it/editing.rs::dragging_a_paragraph_moves_it_and_its_edge_rewraps_it", "crates/ui-egui/tests/it/editing.rs::a_paragraph_rewraps_from_the_outer_half_of_its_edge_handles", "crates/ui-egui/tests/it/editing.rs::a_quick_flick_on_either_edge_rewraps_the_paragraph", "crates/ui-egui/tests/it/editing.rs::an_open_paragraph_moves_by_its_frame_and_by_the_arrow_keys", "crates/ui-egui/tests/it/editing.rs::arrow_keys_nudge_a_selected_image_instead_of_turning_the_page", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools"]
|
||||
notes = "existing paragraphs: drag the box to move it, drag the handle on its left or right edge to rewrap it to a new width (upright pages), including the half of the handle outside the box, which started an area selection instead (#2276); a wrap rejoins when the box is widened, and a newline that was typed stays; an unmarked line that fills the measure rejoins, a short line does not; the move is applied in page space through the paragraph's CTM, in its own q … Q; a click still opens it for typing (#40); once open, its frame grabs the box (click to select, drag to move) while the text keeps the caret, and the arrow keys nudge the selected box or image a point, Shift ten points (#1520)"
|
||||
|
||||
[[feature]]
|
||||
id = "edit.paragraph-detection"
|
||||
|
||||
Reference in New Issue
Block a user