mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-31 21:48:09 -06:00
M7: close a partial rewrite's marker after the paragraph (#2517)
A row left in the source stays in the last paragraph. An extra marker is not placed across two marked-content regions.
This commit is contained in:
@@ -21,6 +21,7 @@
|
||||
> **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M2, #3574: named form properties retain page origin and own dictionary boundaries; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M4: initial-view options resolve indirect boolean preferences; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-16; **Last updated:** 2026-10-16; **Change:** minor (M5 #2669, item 5: reply groups and page root counts per frame; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M7 #2517: a partial rewrite keeps its marker around a kept row, and off the next marked region; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7: the Edit text caret is as tall as the row it is on; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: CSV tab-containing headers; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged)
|
||||
@@ -204,6 +205,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged.
|
||||
- **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged.
|
||||
- **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged.
|
||||
- **2026-10-18 (M7, #2517):** A partial rewrite of the last paragraph closes its marker after that paragraph's last `ET`, so a row left in the source stays in the paragraph. The extra marker is not added across two marked-content regions. Estimates unchanged.
|
||||
- **2026-10-15 (M7, #2517):** A blank line between paragraphs stays a paragraph break. An explicit `TL` is the measured baseline gap. Without one, three or more rows at a repeated step stay one paragraph, and a single wider gap stays a break. Adding a row, even when an earlier row is left in the source, does not absorb the next paragraph on a later edit. Estimates unchanged.
|
||||
|
||||
- **2026-10-23 (M7, #4280):** Edit text names each standard stand-in font after its face (`PCEdTimesBold`, ...) instead of one shared `PCEdHelv`. Rewriting a bold paragraph whose subset lacks an accent no longer repoints the paragraphs set earlier in a regular stand-in at Times-Bold, and the editor's frame stays on the paragraph being edited. Pages saved by older builds keep their shared name and are not repaired. Estimates unchanged.
|
||||
|
||||
@@ -9579,3 +9579,36 @@ fn paragraph_grouping_survives_save_and_reopen_through_tools() {
|
||||
let extracted = page_text(&mut a, again);
|
||||
assert!(extracted[0].contains("Neighbor stays.") && extracted[0].contains("Four"), "{extracted:?}");
|
||||
}
|
||||
|
||||
/// Editing the only paragraph on the page, while leaving its second row in the source, stays
|
||||
/// one paragraph through save and reopen. A neighbour in a different marked region stays in
|
||||
/// its own paragraph.
|
||||
#[test]
|
||||
fn a_partial_rewrite_of_the_last_paragraph_stays_one_through_tools() {
|
||||
let dir = workdir("paragraph-tail");
|
||||
let mut a = auto(&dir);
|
||||
let only = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET";
|
||||
std::fs::write(dir.join("only.pdf"), helvetica_page(only)).unwrap();
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "only.pdf" }))["doc"].as_u64().unwrap();
|
||||
assert_eq!(paragraph_texts(&mut a, doc), ["Alpha first row. Alpha next row."]);
|
||||
ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "text": "One.\nAlpha next row." }));
|
||||
assert_eq!(paragraph_texts(&mut a, doc), ["One. Alpha next row."]);
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "only.pdf" }));
|
||||
let reopened = ok(&mut a, "doc_open", json!({ "path": "only.pdf" }))["doc"].as_u64().unwrap();
|
||||
assert_eq!(paragraph_texts(&mut a, reopened), ["One. Alpha next row."]);
|
||||
ok(&mut a, "text_edit", json!({ "doc": reopened, "page": 1, "paragraph": 1, "text": "One.\nTwo." }));
|
||||
assert_eq!(paragraph_texts(&mut a, reopened), ["One. Two."]);
|
||||
|
||||
let marked = "/H1 << /MCID 0 >> BDC BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET EMC \
|
||||
/P << /MCID 1 >> BDC BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Neighbor stays.) Tj ET EMC";
|
||||
std::fs::write(dir.join("marked.pdf"), helvetica_page(marked)).unwrap();
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "marked.pdf" }))["doc"].as_u64().unwrap();
|
||||
assert_eq!(paragraph_texts(&mut a, doc).len(), 2);
|
||||
ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "text": "One.\nAlpha next row." }));
|
||||
assert_eq!(paragraph_texts(&mut a, doc), ["One. Alpha next row.", "Neighbor stays."]);
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "marked.pdf" }));
|
||||
let reopened = ok(&mut a, "doc_open", json!({ "path": "marked.pdf" }))["doc"].as_u64().unwrap();
|
||||
assert_eq!(paragraph_texts(&mut a, reopened), ["One. Alpha next row.", "Neighbor stays."]);
|
||||
ok(&mut a, "text_edit", json!({ "doc": reopened, "page": 1, "paragraph": 1, "text": "One.\nTwo." }));
|
||||
assert_eq!(paragraph_texts(&mut a, reopened), ["One. Two.", "Neighbor stays."]);
|
||||
}
|
||||
|
||||
@@ -2986,6 +2986,145 @@ fn adding_a_row_does_not_make_the_next_paragraph_number_mean_charlie() {
|
||||
assert!(done.iter().any(|t| t.contains("Bravo paragraph, edited.")), "{done:?}");
|
||||
}
|
||||
|
||||
/// The paragraph is the last thing on the page. Editing the first row and leaving the second
|
||||
/// in the source must keep both rows inside one marker, including after a save.
|
||||
#[test]
|
||||
fn a_partial_rewrite_of_the_only_paragraph_stays_one_block() {
|
||||
let src = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET";
|
||||
let mut doc = text_page(src);
|
||||
text::replace_block(&mut doc, 0, 0, "One.\nAlpha next row.").unwrap();
|
||||
let texts: Vec<_> = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect();
|
||||
assert_eq!(texts, ["One. Alpha next row."], "{texts:?}");
|
||||
assert_marker_wraps(&page_content(&doc), "/P BMC", "(One.)", "(Alpha next row.)");
|
||||
let doc = reopen(&doc);
|
||||
let texts: Vec<_> = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect();
|
||||
assert_eq!(texts, ["One. Alpha next row."], "{texts:?}");
|
||||
assert_marker_wraps(&page_content(&doc), "/P BMC", "(One.)", "(Alpha next row.)");
|
||||
let mut doc = doc;
|
||||
text::replace_block(&mut doc, 0, 0, "One.\nTwo.").unwrap();
|
||||
let texts: Vec<_> = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect();
|
||||
assert_eq!(texts, ["One. Two."], "{texts:?}");
|
||||
assert_marker_wraps(&page_content(&doc), "/P BMC", "(One.)", "(Two.)");
|
||||
}
|
||||
|
||||
/// The same rewrite when an operator follows the paragraph's `ET`. The close belongs before
|
||||
/// that operator, and still after the kept row.
|
||||
#[test]
|
||||
fn a_partial_rewrite_closes_before_the_operator_that_follows_the_paragraph() {
|
||||
let src = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET 0 0 0 RG";
|
||||
let mut doc = text_page(src);
|
||||
text::replace_block(&mut doc, 0, 0, "One.\nAlpha next row.").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let texts: Vec<_> = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect();
|
||||
assert_eq!(texts, ["One. Alpha next row."], "{texts:?}");
|
||||
let content = page_content(&doc);
|
||||
assert_marker_wraps(&content, "/P BMC", "(One.)", "(Alpha next row.)");
|
||||
let start = content.find("/P BMC").expect("marker");
|
||||
let close = content[start..].find("EMC").map(|at| start + at).expect("close");
|
||||
let after = content.find("0 0 0 RG").expect("the following operator is gone");
|
||||
assert!(close < after, "the marker swallowed the operator after the paragraph: {content}");
|
||||
}
|
||||
|
||||
/// The last paragraph on the page is already inside a marked region. A partial rewrite stays
|
||||
/// one paragraph, and that region's close still comes after both rows.
|
||||
#[test]
|
||||
fn a_partial_rewrite_of_the_last_marked_paragraph_stays_inside_its_region() {
|
||||
let src = "/H1 << /MCID 0 >> BDC BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET EMC";
|
||||
let mut doc = text_page(src);
|
||||
assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 1);
|
||||
text::replace_block(&mut doc, 0, 0, "One.\nAlpha next row.").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let texts: Vec<_> = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect();
|
||||
assert_eq!(texts, ["One. Alpha next row."], "{texts:?}");
|
||||
assert_marker_wraps(&page_content(&doc), "/MCID 0", "(One.)", "(Alpha next row.)");
|
||||
}
|
||||
|
||||
/// A partial rewrite must not insert `/P BMC` across two existing marked-content regions.
|
||||
/// The neighbour's own marker has to keep wrapping its text.
|
||||
#[test]
|
||||
fn a_partial_rewrite_keeps_the_next_marked_region_closed() {
|
||||
let src = "/H1 << /MCID 0 >> BDC BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET EMC \
|
||||
/P << /MCID 1 >> BDC BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Neighbor stays.) Tj ET EMC";
|
||||
let mut doc = text_page(src);
|
||||
assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 2);
|
||||
text::replace_block(&mut doc, 0, 0, "One.\nAlpha next row.").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let texts: Vec<_> = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect();
|
||||
assert_eq!(texts, ["One. Alpha next row.", "Neighbor stays."], "{texts:?}");
|
||||
let content = page_content(&doc);
|
||||
assert_marker_wraps(&content, "/MCID 0", "(One.)", "(Alpha next row.)");
|
||||
assert_marker_wraps(&content, "/MCID 1", "(Neighbor stays.)", "(Neighbor stays.)");
|
||||
let neighbour = content.find("/MCID 1").expect("neighbour region");
|
||||
let close = content[neighbour..].find("EMC").map(|at| neighbour + at).expect("neighbour close");
|
||||
assert!(!content[neighbour..close].contains("(One.)"), "the rewrite was pulled into the neighbour's marker: {content}");
|
||||
}
|
||||
|
||||
/// Both paragraphs already share one marked region, separated by a blank line. The extra marker
|
||||
/// around the edit has to close itself, not the region, so the neighbour stays inside it.
|
||||
#[test]
|
||||
fn a_partial_rewrite_inside_one_marked_region_leaves_the_neighbour_inside_it() {
|
||||
let src = "/P << /MCID 0 >> BDC BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET \
|
||||
BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Neighbor stays.) Tj ET EMC";
|
||||
let mut doc = text_page(src);
|
||||
assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 2);
|
||||
text::replace_block(&mut doc, 0, 0, "One.\nAlpha next row.").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let texts: Vec<_> = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect();
|
||||
assert_eq!(texts, ["One. Alpha next row.", "Neighbor stays."], "{texts:?}");
|
||||
let content = page_content(&doc);
|
||||
let region = region_through_matching_close(&content, "/MCID 0");
|
||||
assert!(region.contains("(One.)") && region.contains("(Neighbor stays.)"), "the region closed before the neighbour: {content}");
|
||||
}
|
||||
|
||||
/// Bytes from `open` through the `EMC` that balances the marked region it begins.
|
||||
fn region_through_matching_close<'a>(content: &'a str, open: &str) -> &'a str {
|
||||
let start = content.find(open).unwrap_or_else(|| panic!("missing {open}: {content}"));
|
||||
let bytes = content.as_bytes();
|
||||
let mut index = start;
|
||||
let mut depth = 0i32;
|
||||
while index < bytes.len() {
|
||||
let rest = &bytes[index..];
|
||||
let boundary = index == 0 || !bytes[index - 1].is_ascii_alphanumeric();
|
||||
if boundary && (rest.starts_with(b"BMC") || rest.starts_with(b"BDC")) && rest.get(3).is_none_or(|b| !b.is_ascii_alphanumeric()) {
|
||||
depth += 1;
|
||||
index += 3;
|
||||
continue;
|
||||
}
|
||||
if boundary && rest.starts_with(b"EMC") && rest.get(3).is_none_or(|b| !b.is_ascii_alphanumeric()) {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
return &content[start..index];
|
||||
}
|
||||
index += 3;
|
||||
continue;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
panic!("unbalanced region {open}: {content}");
|
||||
}
|
||||
|
||||
fn page_content(doc: &Document) -> String {
|
||||
String::from_utf8_lossy(&page_content_bytes(doc, 0)).into_owned()
|
||||
}
|
||||
|
||||
/// `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];
|
||||
assert!(marked.contains(first) && marked.contains(second), "{open} closed too early: {content}");
|
||||
let mut text_objects = 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}"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert_eq!(text_objects, 0, "a text object was left open: {content}");
|
||||
}
|
||||
|
||||
/// A blank line is one extra leading, so the fourth row of a grown paragraph sits on the
|
||||
/// neighbour's baseline. Changing the box width must not treat that neighbour as a duplicate
|
||||
/// drawing and delete it.
|
||||
|
||||
@@ -610,7 +610,7 @@ fn untouched_rows(block: &TextBlock, draft: &str, rows: &[std::ops::Range<usize>
|
||||
}
|
||||
|
||||
/// Operator index of the paragraph's first text object, and of the next line's text object
|
||||
/// when one follows. The next index is where a closing marker belongs.
|
||||
/// when one follows.
|
||||
fn paragraph_span(block: &TextBlock, lines: &[TextLine]) -> Option<(usize, Option<usize>)> {
|
||||
let belongs: std::collections::HashSet<usize> = block.lines.iter().copied().collect();
|
||||
let start = block.lines.iter().filter_map(|i| lines.get(*i)).map(|line| line.origin.bt_op).min()?;
|
||||
@@ -625,6 +625,50 @@ fn paragraph_span(block: &TextBlock, lines: &[TextLine]) -> Option<(usize, Optio
|
||||
Some((start, next))
|
||||
}
|
||||
|
||||
/// The scope shared by every line of the paragraph, when each one is in the same marked region.
|
||||
fn shared_scope(block: &TextBlock, lines: &[TextLine]) -> Option<usize> {
|
||||
let mut scopes = block.lines.iter().filter_map(|i| lines.get(*i)).map(|line| line.scope);
|
||||
let first = scopes.next().flatten()?;
|
||||
scopes.all(|scope| scope == Some(first)).then_some(first)
|
||||
}
|
||||
|
||||
/// Where an extra `/P` marker belongs.
|
||||
///
|
||||
/// The marker is omitted when the paragraph is already one marked region and the next line is
|
||||
/// in a different one: an `EMC` at that line's `BT` lands after the other region has opened, so
|
||||
/// it would close the other region and leave its text outside. When nothing follows, `close` is
|
||||
/// the operator just after the paragraph's last `ET` (`ops.len()` means append it after the
|
||||
/// stream). Closing earlier, inside the rewritten operators, leaves a kept source row outside
|
||||
/// the marker and can put `EMC` inside a `BT`.
|
||||
fn paragraph_marker(block: &TextBlock, lines: &[TextLine], ops: &[Op]) -> Option<(usize, Option<usize>, usize)> {
|
||||
let (start, next) = paragraph_span(block, lines)?;
|
||||
if let Some(region) = shared_scope(block, lines)
|
||||
&& let Some(at) = next
|
||||
{
|
||||
let next_scope = lines.iter().find(|line| line.origin.bt_op == at).map(|line| line.scope);
|
||||
if next_scope != Some(Some(region)) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let close = if next.is_none() {
|
||||
let end = block
|
||||
.lines
|
||||
.iter()
|
||||
.filter_map(|i| lines.get(*i))
|
||||
.flat_map(|line| line.ops.iter().copied().chain(std::iter::once(line.origin.bt_op)))
|
||||
.max()?;
|
||||
ops.iter()
|
||||
.enumerate()
|
||||
.skip(end.saturating_add(1))
|
||||
.find(|(_, op)| op.op.as_slice() == b"ET")
|
||||
.map(|(index, _)| index.saturating_add(1))
|
||||
.unwrap_or(ops.len())
|
||||
} else {
|
||||
0
|
||||
};
|
||||
Some((start, next, close))
|
||||
}
|
||||
|
||||
struct RewriteAnchor<'a> {
|
||||
lead: &'a TextLine,
|
||||
drop: std::collections::HashSet<usize>,
|
||||
@@ -1001,26 +1045,41 @@ pub(crate) fn rewrite_styled_block(
|
||||
block_ops.push(Op::new("Tm", o.tlm.iter().map(|v| n(*v)).collect()));
|
||||
}
|
||||
let at = if split { start } else { o.bt_op };
|
||||
// A kept source row sits outside `block_ops`, so a marker only around the rewrite splits
|
||||
// the paragraph or leaves the added row to join the neighbour. Bound the whole paragraph:
|
||||
// from its first text object up to, but not including, the next line.
|
||||
let marker = if marked { None } else { paragraph_span(block, lines) };
|
||||
if marker.is_some_and(|(_, next)| next.is_none()) {
|
||||
block_ops.push(Op::new("EMC", vec![]));
|
||||
}
|
||||
// A kept source row sits outside `block_ops`. Bound the whole paragraph when a marker is
|
||||
// safe. The close is not part of `block_ops`: that vector can end inside a text object
|
||||
// when the rewrite splits one, and a kept row's operators come later in the stream.
|
||||
let marker = if marked { None } else { paragraph_marker(block, lines, &ops) };
|
||||
let data = splice(&pieces, &ops, |i| {
|
||||
let mut insert = Vec::new();
|
||||
if marker.is_some_and(|(start_at, _)| i == start_at) {
|
||||
insert.push(Op::new("BMC", vec![Object::name("P")]));
|
||||
if let Some((start_at, next, close)) = marker {
|
||||
if i == start_at {
|
||||
insert.push(Op::new("BMC", vec![Object::name("P")]));
|
||||
}
|
||||
if next.is_none() && i == close {
|
||||
insert.push(Op::new("EMC", vec![]));
|
||||
}
|
||||
if next == Some(i) {
|
||||
insert.push(Op::new("EMC", vec![]));
|
||||
}
|
||||
}
|
||||
if i == at {
|
||||
insert.append(&mut block_ops);
|
||||
}
|
||||
if marker.is_some_and(|(_, next)| next == Some(i)) {
|
||||
insert.push(Op::new("EMC", vec![]));
|
||||
}
|
||||
(insert, !drop.contains(&i))
|
||||
});
|
||||
let data = if marker.is_some_and(|(_, next, close)| next.is_none() && close >= ops.len()) {
|
||||
let mut data = data;
|
||||
if let Some(last) = data.last_mut() {
|
||||
if last.last().is_some_and(|byte| !byte.is_ascii_whitespace()) {
|
||||
last.push(b'\n');
|
||||
}
|
||||
// `splice` never visits `ops.len()`, so a close after the final operator is appended.
|
||||
last.extend_from_slice(&pdfcraft_content::serialize_ops(&[Op::new("EMC", vec![])]));
|
||||
}
|
||||
data
|
||||
} else {
|
||||
data
|
||||
};
|
||||
let contents = rewritten_contents(doc, &streams, data);
|
||||
res.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
doc.update_dict(p.obj, |d| {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "last paragraph keeps both rows",
|
||||
"content": "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET",
|
||||
"steps": [
|
||||
{"command": {"id": "edit.edit_text"}},
|
||||
{"click_page": {"x": 120, "y": 704}},
|
||||
{"type": {"text": "One.\nAlpha next row."}},
|
||||
{"key": {"key": "Enter", "modifiers": ["command"]}},
|
||||
{"expect_together": {"parts": ["One.", "Alpha next row."]}},
|
||||
{"click_page": {"x": 120, "y": 704}},
|
||||
{"expect_editor": {"contains": "One.", "text": "One.\nAlpha next row."}},
|
||||
{"shot": {"name": "last-paragraph-keeps-both-rows"}}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "marked region keeps neighbor",
|
||||
"content": "/H1 << /MCID 0 >> BDC BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET EMC /P << /MCID 1 >> BDC BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Neighbor stays.) Tj ET EMC",
|
||||
"steps": [
|
||||
{"command": {"id": "edit.edit_text"}},
|
||||
{"click_page": {"x": 120, "y": 704}},
|
||||
{"type": {"text": "One.\nAlpha next row."}},
|
||||
{"key": {"key": "Enter", "modifiers": ["command"]}},
|
||||
{"expect_together": {"parts": ["One.", "Alpha next row."]}},
|
||||
{"expect_apart": {"text": "Neighbor stays.", "not": "One."}},
|
||||
{"click_page": {"x": 140, "y": 660}},
|
||||
{"expect_editor": {"contains": "Neighbor stays.", "text": "Neighbor stays."}},
|
||||
{"shot": {"name": "marked-region-keeps-neighbor"}}
|
||||
]
|
||||
}
|
||||
+4
-1
@@ -5,6 +5,7 @@
|
||||
|
||||
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-19 · **Change:** minor (gap 2: composite fonts with `/ToUnicode /Identity-H` read as Unicode, hayro-interpret patch 44; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (gap 6: #2662 redaction audit counts a cover over one word of a long line; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (gap 3: #2517 a partial rewrite keeps a kept row inside the paragraph and leaves the next marked region closed; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (gap 12: reading order is scored, not just word F1) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M1: raw-deflate fallback preserves decoding-limit errors; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (gap 14: Vietnamese catalog shipped; Hindi/Indonesian/Korean and Arabic RTL remain) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
@@ -103,7 +104,8 @@ update the numbers.
|
||||
removes a distinct neighbour whose glyph box overlaps (#1316); a second drawing on the same
|
||||
baseline is still replaced. A blank line stays a paragraph break. An explicit `TL` is the
|
||||
measured baseline gap, and growing a paragraph so its new row is one leading above the next
|
||||
one does not absorb that paragraph (#2517). A heading and smaller body lines (#1756), a
|
||||
one does not absorb that paragraph (#2517). A partial rewrite of the last paragraph keeps a
|
||||
source row inside that paragraph, and does not close a following marked region early. A heading and smaller body lines (#1756), a
|
||||
hanging-indent continuation (#2564), and cells in different columns (#1680, #2478) are still
|
||||
grouped by the same geometry. Estimates are unchanged.
|
||||
|
||||
@@ -409,6 +411,7 @@ update the numbers.
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-25 | minor | Gaps 5 and 14 current state: native release builds embed LXGW WenKai Lite for Simplified Chinese, while the web build has no Hans face; Arabic catalogs stay logical with explicit RTL label direction and mirrored stock chrome, while native/browser RTL input, IME and complex workflows remain unqualified. Estimates unchanged. |
|
||||
| 2026-10-18 | minor | Gap 3: #2517 a partial rewrite keeps a kept source row in the paragraph and leaves the next marked region closed around its text. Estimates unchanged. |
|
||||
| 2026-10-15 | minor | Gap 3: #2517 a blank line at ordinary leading stays a paragraph break. Estimates unchanged. |
|
||||
| 2026-10-19 | minor | Gap 2: a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (WSCAD) reads its codes as Unicode, so such pages have text to select, find and edit (hayro-interpret patch 44, `view.render-cid-fonts`). Estimates unchanged. |
|
||||
| 2026-10-15 | minor | Gap 21: the side-by-side view had already shipped; the Compare panel filters text changes and visual differences. Estimate 10–20 h → 8–16 h |
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
> **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M10: table spans retain text from covered slots in Office exports; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M10: image export preserves soft-mask Decode opacity ranges; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M10: vector printing preserves existing page XObjects when adding annotation appearances; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M7 #2517: a partial rewrite keeps its marker around a kept row, and off the next marked region; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7 #2517: a blank line stays a paragraph break; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
|
||||
> **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M10, #3532 partial: vector printing retains existing appearances with absent Subtype; estimates unchanged)
|
||||
@@ -265,6 +266,7 @@ Estimates unchanged.
|
||||
| 2026-10-21 | minor | M3: on GNOME/Wayland, a second resize by an edge or move by the tab strip works without a click in between (the compositor kept the button's release from egui). Estimates unchanged. |
|
||||
| 2026-10-22 | minor | M4, #3916 partial: edits and Undo/Redo cancel Organize page drags. Pointer-release and save/reopen regressions check page order. Estimates unchanged. |
|
||||
| 2026-10-22 | minor | M14 #3558: rebased macOS association recovery; reuse #3042 shared metadata preservation instead of a duplicate save wrapper. Estimates unchanged. |
|
||||
| 2026-10-18 | minor | M7 #2517: a partial rewrite of the last paragraph closes its marker after the last `ET`, and does not close a following marked region early. Estimates unchanged. |
|
||||
| 2026-10-15 | minor | M7 #2517: a blank line between paragraphs stays a paragraph break, and growing the first paragraph does not absorb the next one. Estimates unchanged. |
|
||||
| 2026-10-20 | minor | M4: initial-view window and interface options resolve indirect boolean ViewerPreferences values; synthetic true/false and save/reopen regressions retain defaults for malformed entries. Estimates unchanged. |
|
||||
| 2026-10-21 | minor | M3, #3808: the Pages inspector has an independent 50–150% thumbnail size slider and buttons; selection and current-page following are retained, with headless ui.set coverage. Estimates unchanged. |
|
||||
|
||||
@@ -1914,8 +1914,8 @@ milestone = "M7"
|
||||
status = "shipped"
|
||||
commands = ["edit.edit_text"]
|
||||
tools = ["text_paragraphs"]
|
||||
evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::a_blank_line_keeps_the_next_paragraph", "crates/edit/src/tests.rs::a_grown_paragraph_does_not_swallow_the_next_one", "crates/automation/tests/it/automation.rs::a_blank_line_stays_a_separate_paragraph_through_tools", "crates/automation/tests/it/automation.rs::paragraph_grouping_survives_save_and_reopen_through_tools", "crates/edit/src/tests.rs::a_wrapped_list_item_keeps_its_continuation_and_the_items_after_it", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools"]
|
||||
notes = "lines grouped into paragraphs by stream, font, size, left alignment and regular baseline spacing; a blank line (about twice the paragraph's own leading) stays a separate paragraph, and editing the first one leaves the next in place (#2517). An explicit TL is that leading. Without one, Td does not set it: three or more rows at one repeated step stay one paragraph, and a single wider gap stays a break. A rewrite marks the whole paragraph, including a row it leaves in the source, so adding a row does not absorb the next paragraph on a later edit. Hyphenated line ends are joined. A row opening with a list marker starts its own item, and the row a list item wraps onto continues it at its hanging indent, so editing one item never takes the items after it. Still open: a larger heading joined to smaller body lines (#1756) and cells in different columns (#1680, #2478). Two rows and no third step still cannot be told from a paragraph break when there is no TL."
|
||||
evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::a_wrapped_list_item_keeps_its_continuation_and_the_items_after_it", "crates/edit/src/tests.rs::a_blank_line_keeps_the_next_paragraph", "crates/edit/src/tests.rs::a_grown_paragraph_does_not_swallow_the_next_one", "crates/automation/tests/it/automation.rs::a_blank_line_stays_a_separate_paragraph_through_tools", "crates/automation/tests/it/automation.rs::paragraph_grouping_survives_save_and_reopen_through_tools", "crates/edit/src/tests.rs::a_partial_rewrite_of_the_only_paragraph_stays_one_block", "crates/edit/src/tests.rs::a_partial_rewrite_closes_before_the_operator_that_follows_the_paragraph", "crates/edit/src/tests.rs::a_partial_rewrite_of_the_last_marked_paragraph_stays_inside_its_region", "crates/edit/src/tests.rs::a_partial_rewrite_keeps_the_next_marked_region_closed", "crates/edit/src/tests.rs::a_partial_rewrite_inside_one_marked_region_leaves_the_neighbour_inside_it", "crates/automation/tests/it/automation.rs::a_partial_rewrite_of_the_last_paragraph_stays_one_through_tools", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools"]
|
||||
notes = "lines grouped into paragraphs by stream, font, size, left alignment and regular baseline spacing; a blank line (about twice the paragraph's own leading) stays a separate paragraph, and editing the first one leaves the next in place (#2517). An explicit TL is that leading. Without one, Td does not set it: three or more rows at one repeated step stay one paragraph, and a single wider gap stays a break. A rewrite marks the whole paragraph, including a row it leaves in the source, so adding a row does not absorb the next paragraph on a later edit. When the paragraph is the last text on the page, that marker closes after the paragraph's last ET, so a kept source row stays inside it. The extra marker is not added when the paragraph is already one marked region and the next line is in a different region. Hyphenated line ends are joined. A row opening with a list marker starts its own item, and the row a list item wraps onto continues it at its hanging indent, so editing one item never takes the items after it. Still open: a larger heading joined to smaller body lines (#1756) and cells in different columns (#1680, #2478). Two rows and no third step still cannot be told from a paragraph break when there is no TL."
|
||||
|
||||
[[feature]]
|
||||
id = "edit.font-reuse-substitution"
|
||||
|
||||
Reference in New Issue
Block a user