#1327: protection checks cover tables, Paste Formatting and recipient refresh (#2618)

* #1327: protection checks cover tables, Paste Formatting, template update and recipient refresh

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* #1327: record any content below the protection walk's depth limit

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* #1327: formatting-only protection also refuses table properties and row height

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Dmitrii Khlapov
2026-10-18 11:45:40 -04:00
committed by GitHub
co-authored by Claude Sonnet 5.5 Dmitrii Khlapov Brandon Thomas
parent 8c812bf1d6
commit 6e30349bbb
4 changed files with 226 additions and 9 deletions
+2
View File
@@ -141,6 +141,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
| Date | What landed |
| 2026-10-18 | Symbols keep their font (#2036): a `w:sym` or Insert › Symbol character in Wingdings, Symbol or Webdings remembers its symbol font apart from the text's font, so selecting all and changing the font (or Clear Formatting) no longer turns the symbols into boxes; saved back to DOCX as `w:sym`, so Word shows them too. Typed text and the Format Painter don't pick up the symbol font. |
| 2026-10-16 | `wordcraft-cli run --script FILE.json` (`-` for stdin) runs a JSON list of commands, then saves to one or more outputs (docx and pdf in one run); errors name the command number and id and nothing is saved, #1963 |
| 2026-10-17 | Restrict Editing holds for structure too (#1327): an exception inside a table cell no longer lets table, row or cell formatting change the protected rest of the table; formatting limited to styles refuses Paste Formatting, table shading, borders, cell alignment, cell margins, text direction, table-style edits and locked table styles; loading, filtering or sorting recipients no longer rewrites merge-field results in a protected document. |
| 2026-10-14 | RTF export writes every section (gap 19): `\sect` ends each section and `\sectd` carries its break type, page size and orientation, margins, columns, page numbering and title page, so multi-section documents survive an RTF round trip |
| 2026-10-14 | `ASK` and `SET` fields no longer show their stored result on the page or in PDFs (#1765): like Word, they only assign text to a bookmark and draw nothing; the stored result is kept and saved |
| 2026-10-15 | Table rows with an at-least height start the next page whole when less than their minimum is left on a page that already holds something, so one-row-per-page booklets keep their pages; rows taller than a page split from the top of the next page and repeated header rows go with the row (#1742) |
@@ -488,6 +489,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
| Date | Change | Summary |
|---|---|---|
| 2026-10-18 | trivial | Progress log: symbols keep their font when the text font changes (#2036) |
| 2026-10-17 | trivial | Progress log: Restrict Editing guards tables, Paste Formatting and merge fields (#1327) |
| 2026-10-14 | trivial | Progress log: a list item's first word is not flagged for a missing capital (#1741) |
| 2026-10-18 | trivial | #2445: pending Save As answers keep their originating document identity, so a replacement document is never written to an old picker destination; real Save-button and focused-editor shortcut controls verified |
| 2026-10-16 | trivial | #2445: interactive Save on import-only .doc/.dot paths uses the existing DOCX Save As flow; cancel and failed writes keep the draft, and Close waits for a successful save. Explicit/script saves still report unsupported legacy output |
+11 -2
View File
@@ -226,7 +226,16 @@ fn apply_filter(s: &mut Session) {
if m.rows.is_empty() {
m.preview = false;
}
refresh(s);
refresh_shown(s);
}
/// [`refresh`] for the non-mutating recipient-list commands: in a protected document the merge
/// fields' shown results stay as they are, since these commands skip the protection checks and
/// undo (Preview Results and the record buttons, which mutate, show the new list's values).
fn refresh_shown(s: &mut Session) {
if !super::protect::enforced(&s.doc) {
refresh(s);
}
}
/// The canonical field name for `f` (any case), or a params error.
@@ -647,7 +656,7 @@ fn recipients(s: &mut Session, v: &Value) -> CmdResult {
s.merge.filter.clear();
s.merge.sort.clear();
s.merge.record = 0;
refresh(s);
refresh_shown(s);
let mut r = json!({"fields": s.merge.headers, "records": s.merge.rows.len()});
if !sheet.is_null() {
r["sheet"] = sheet;
+211 -7
View File
@@ -10,8 +10,8 @@
use serde_json::{Value, json};
use std::collections::HashSet;
use wordcraft_doc::para::{InlineObject, OBJ};
use wordcraft_doc::props::CharProps;
use wordcraft_doc::{Document, Path, PermRange, Pos, StoryRef};
use wordcraft_doc::props::{CellProps, CharProps, RowProps, TableProps};
use wordcraft_doc::{Block, Blocks, Document, Path, PermRange, Pos, StoryRef};
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
@@ -232,7 +232,7 @@ pub(crate) fn check_before(s: &Session, id: &str, params: &Value) -> Result<bool
/// Formatting limited to a selection of styles: no direct formatting, and no locked style.
fn check_formatting(s: &Session, id: &str, params: &Value) -> Result<(), CmdError> {
let style = match id {
"para.style" | "format.charStyle" => p::str(params, "style"),
"para.style" | "format.charStyle" | "table.style" => p::str(params, "style"),
"para.normal" => Some("Normal"),
"para.heading1" => Some("Heading1"),
"para.heading2" => Some("Heading2"),
@@ -248,7 +248,26 @@ fn check_formatting(s: &Session, id: &str, params: &Value) -> Result<(), CmdErro
|| id.starts_with("styles.create")
|| id.starts_with("styles.modify")
|| id.starts_with("styles.update")
|| id.starts_with("styles.delete");
|| id.starts_with("styles.delete")
// Direct formatting outside the prefixes: Paste Formatting, and table and cell formatting.
|| matches!(
id,
"edit.pasteFormat"
| "table.shading"
| "table.borders"
| "table.borderPainter"
| "table.cellAlign"
| "table.cellMargins"
| "table.textDirection"
| "table.properties"
| "table.rowHeight"
| "table.newStyle"
| "table.modifyStyle"
| "table.deleteStyle"
| "tools.updateStyles"
)
// Attach Template updates the styles from it when asked (`linkStyles` is only a setting).
|| (id == "tools.attachTemplate" && p::bool(params, "update") == Some(true));
if direct {
return Err(CmdError::Disabled(format!("{id}: formatting is limited to the document's styles")));
}
@@ -265,8 +284,9 @@ enum HoleMode {
/// Did a command leave everything outside the editable regions as it was? Compares the text,
/// run formatting and paragraph formatting outside the ranges `author` may edit, in every story,
/// and what reaches the whole document: page setup, the document defaults and the existing
/// styles (applying a style may add one).
/// and the formatting of every table, row and cell that holds any of that protected content, and what
/// reaches the whole document: page setup, the document defaults and the existing styles
/// (applying a style may add one).
pub(crate) fn outside_unchanged(before: &Document, after: &Document, author: &str) -> bool {
// Protection can't change mid-command (`protection_lifted` guards undo and redo).
let mode = if Some("forms") == before.settings.protection.as_deref() { HoleMode::Controls } else { HoleMode::Perms(author.into()) };
@@ -284,12 +304,18 @@ fn sections(doc: &Document) -> Vec<&wordcraft_doc::SectionProps> {
doc.para_paths(StoryRef::Body).iter().filter_map(|path| doc.para(StoryRef::Body, path)?.section.as_deref()).collect()
}
/// One piece of protected content: text with its formatting, or a paragraph mark's formatting.
/// One piece of protected content: text with its formatting, a paragraph mark's formatting, or
/// the formatting of a table, row or cell holding protected content.
#[derive(PartialEq, Debug)]
enum Piece {
Text(String, CharProps),
Object(Option<InlineObject>),
Mark(Box<wordcraft_doc::ParaProps>, CharProps),
/// Tables nested deeper than the paragraph walk reaches, compared as a whole.
Deep(String),
Table(Box<TableProps>, Vec<f32>),
Row(Box<RowProps>),
Cell(Box<CellProps>),
}
/// The control markers of a story that pair, bracket-style — the start and end positions of
@@ -320,8 +346,12 @@ fn outside(doc: &Document, mode: &HoleMode) -> Vec<Piece> {
HoleMode::Perms(_) => !open.is_empty(),
HoleMode::Controls => fillable.last() == Some(&true),
};
// The paragraphs with protected content, and every table, row and cell above them.
let mut protected: HashSet<Vec<u32>> = HashSet::new();
let mut holding: HashSet<Vec<u32>> = HashSet::new();
for path in doc.para_paths(story) {
let Some(p) = doc.para(story, &path) else { continue };
let pieces = out.len();
let mut k = 0;
for (r, props) in p.run_ranges() {
let Some(text) = p.text.get(r.clone()) else { continue };
@@ -377,11 +407,56 @@ fn outside(doc: &Document, mode: &HoleMode) -> Vec<Piece> {
if !in_hole(&open, &fillable) {
out.push(Piece::Mark(Box::new(p.props.clone()), p.mark.clone()));
}
let ancestors = (1..path.0.len()).filter_map(|n| path.0.get(..n));
if out.len() > pieces {
protected.extend(ancestors.clone().map(<[u32]>::to_vec));
}
holding.extend(ancestors.map(<[u32]>::to_vec));
}
if let Some(blocks) = doc.story(story) {
structure(blocks, &mut Vec::new(), &protected, &holding, &mut out, 0);
}
}
out
}
/// The formatting of every table, row and cell in `blocks` that holds protected content (or no
/// paragraph at all): an exception covering only some of it can't reformat the rest.
fn structure(blocks: &Blocks, prefix: &mut Vec<u32>, protected: &HashSet<Vec<u32>>, holding: &HashSet<Vec<u32>>, out: &mut Vec<Piece>, depth: usize) {
// `Document::para_paths` stops at the same depth; deeper nesting can't hold an editable
// exception, so any change below it counts as a change to protected content.
if depth > 16 {
if !blocks.is_empty() {
out.push(Piece::Deep(format!("{blocks:?}")));
}
return;
}
let guarded = |at: &Vec<u32>| protected.contains(at) || !holding.contains(at);
for (i, b) in blocks.iter().enumerate() {
let Block::Table(t) = &**b else { continue };
prefix.push(u32::try_from(i).unwrap_or(u32::MAX));
if guarded(prefix) {
out.push(Piece::Table(Box::new(t.props.clone()), t.grid.clone()));
}
for (r, row) in t.rows.iter().enumerate() {
prefix.push(u32::try_from(r).unwrap_or(u32::MAX));
if guarded(prefix) {
out.push(Piece::Row(Box::new(row.props.clone())));
}
for (c, cell) in row.cells.iter().enumerate() {
prefix.push(u32::try_from(c).unwrap_or(u32::MAX));
if guarded(prefix) {
out.push(Piece::Cell(Box::new(cell.props.clone())));
}
structure(&cell.blocks, prefix, protected, holding, out, depth + 1);
prefix.pop();
}
prefix.pop();
}
prefix.pop();
}
}
fn flush(out: &mut Vec<Piece>, piece: &mut String, props: &CharProps, in_hole: bool) {
if !piece.is_empty() && !in_hole {
out.push(Piece::Text(std::mem::take(piece), props.clone()));
@@ -447,6 +522,111 @@ mod tests {
assert_eq!(s.doc.settings.protection.as_deref(), Some("readOnly"));
}
/// Table, row and cell formatting reaches protected text, so an exception inside one cell
/// doesn't let table commands run there change the table, the row or the cell (#1327).
#[test]
fn read_only_exception_in_a_cell_does_not_open_the_table() {
let mut s = Session::new(Document::from_text("Intro."));
s.run("insert.table", &json!({"rows": 2, "cols": 2})).unwrap();
for (i, t) in ["Alpha", "Bravo", "Charlie", "Delta"].iter().enumerate() {
if i > 0 {
s.run("text.tab", &json!({})).unwrap();
}
s.run("text.insert", &json!({"text": t})).unwrap();
}
select(&mut s, "Alpha");
s.run("review.restrict", &json!({"exception": "add"})).unwrap();
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
// Typing inside the exception still works.
select(&mut s, "lph");
s.run("text.insert", &json!({"text": "LPH"})).unwrap();
select(&mut s, "LPH");
let before = s.doc.clone();
for (id, v) in [
("table.properties", json!({"align": "center"})),
("table.rowHeight", json!({"height": 40})),
("table.shading", json!({"color": "FF0000"})),
("table.cellAlign", json!({"value": "bottomRight"})),
("table.borders", json!({"kind": "none"})),
] {
let e = s.run(id, &v).unwrap_err().to_string();
assert!(e.contains("only the editable regions"), "{id}: {e}");
assert_eq!(s.doc, before, "{id}");
}
}
/// A table wholly inside an exception stays formattable: its table, rows and cells hold no
/// protected content.
#[test]
fn read_only_exception_around_a_table_lets_it_be_formatted() {
let mut s = Session::new(Document::from_text("Locked.\nStart."));
s.sel = Selection::caret(s.doc.end_of(StoryRef::Body));
s.run("insert.table", &json!({"rows": 2, "cols": 2})).unwrap();
s.run("text.insert", &json!({"text": "Alpha"})).unwrap();
select(&mut s, "Start.");
let anchor = s.sel.ordered().0;
s.sel = Selection { anchor, focus: s.doc.end_of(StoryRef::Body) };
s.run("review.restrict", &json!({"exception": "add"})).unwrap();
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
select(&mut s, "Alpha");
s.run("table.properties", &json!({"align": "center"})).unwrap();
s.run("table.rowHeight", &json!({"height": 40})).unwrap();
s.run("table.shading", &json!({"color": "FF0000"})).unwrap();
// The text before the exception is still protected.
select(&mut s, "Locked");
assert!(s.run("format.bold", &json!({})).is_err());
}
/// Formatting limited to styles: attaching a template refuses to update the styles from it
/// (it would bypass the Update Styles block); attaching without updating still works.
#[test]
fn formatting_restriction_refuses_attach_template_update() {
let mut t = Document::from_text("Template.");
if let Some(st) = t.styles.styles.iter_mut().find(|st| st.id == "Heading1") {
st.chr.size = Some(31.0);
}
let data = crate::cmd::insert::base64_encode(&crate::io::save_bytes("t.docx", &t).unwrap());
let mut s = Session::new(Document::from_text("Text."));
s.run("review.restrict", &json!({"mode": "none", "formatting": true})).unwrap();
let before = s.doc.clone();
let e = s.run("tools.attachTemplate", &json!({"path": "Report.dotx", "update": true, "data": data})).unwrap_err().to_string();
assert!(e.contains("formatting is limited"), "{e}");
assert_eq!(s.doc, before);
s.run("tools.attachTemplate", &json!({"path": "Report.dotx"})).unwrap();
assert_eq!(s.doc.settings.attached_template.as_deref(), Some("Report.dotx"));
}
/// Formatting limited to styles: Paste Formatting and table formatting are direct
/// formatting, and a locked table style can't be applied (#1327).
#[test]
fn formatting_restriction_covers_paste_formatting_and_tables() {
let mut s = Session::new(Document::from_text("Bold words.\nplain words."));
s.run("insert.table", &json!({"rows": 1, "cols": 1})).unwrap();
s.run("text.insert", &json!({"text": "Cell"})).unwrap();
select(&mut s, "Bold");
s.run("format.bold", &json!({})).unwrap();
s.run("edit.copyFormat", &json!({})).unwrap();
s.run("review.restrict", &json!({"formatting": true, "lockedStyles": ["TableGrid"]})).unwrap();
s.run("review.restrict", &json!({"mode": "none", "formatting": true})).unwrap();
assert!(s.doc.settings.protect_formatting);
select(&mut s, "plain");
let before = s.doc.clone();
assert!(s.run("edit.pasteFormat", &json!({})).is_err(), "edit.pasteFormat");
select(&mut s, "Cell");
for (id, v) in [
("table.shading", json!({"color": "FF0000"})),
("table.borders", json!({"kind": "none"})),
("table.cellAlign", json!({"value": "bottomRight"})),
("table.properties", json!({"align": "center"})),
("table.rowHeight", json!({"height": 40})),
("table.style", json!({"style": "TableGrid"})),
] {
let e = s.run(id, &v).unwrap_err().to_string();
assert!(e.contains("formatting is limited") || e.contains("is locked"), "{id}: {e}");
}
assert_eq!(s.doc, before);
}
/// Page setup and style definitions reach text outside the exceptions too, so an exception
/// doesn't let them change; applying a character style inside one still works.
#[test]
@@ -479,6 +659,30 @@ mod tests {
assert_eq!(s.doc, before);
}
/// Read only, no exceptions: going to a header the document doesn't have doesn't create
/// one, and loading or filtering recipients doesn't rewrite the merge fields' shown results
/// (both used to change the document from a non-mutating command, #1327).
#[test]
fn read_only_navigation_and_recipients_leave_the_document_alone() {
let mut s = Session::new(Document::from_text("Dear name,"));
select(&mut s, "name");
s.run("mailings.insertField", &json!({"field": "Name"})).unwrap();
s.run("mailings.recipients", &json!({"rows": [{"Name": "Ada"}, {"Name": "Bob"}]})).unwrap();
s.run("mailings.preview", &json!({"value": true})).unwrap();
assert!(s.doc.plain_text(StoryRef::Body).contains("Ada"));
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
let (before, depth) = (s.doc.clone(), s.undo_depth());
assert!(s.run("insert.editHeader", &json!({})).is_err());
assert!(s.run("insert.editFooter", &json!({})).is_err());
s.run("mailings.recipients", &json!({"rows": [{"Name": "Cyd"}]})).unwrap();
s.run("mailings.filterRecipients", &json!({"rules": [{"field": "Name", "op": "equal", "value": "Cyd"}]})).unwrap();
s.run("mailings.sortRecipients", &json!({"field": "Name", "descending": true})).unwrap();
s.run("mailings.editRecipients", &json!({"rows": [{"Name": "Dee"}]})).unwrap();
assert!(s.run("mailings.next", &json!({})).is_err());
assert_eq!(s.doc, before);
assert_eq!(s.undo_depth(), depth);
}
#[test]
fn forms_mode_allows_edits_only_inside_fillable_controls() {
let mut s = Session::new(Document::from_text("Locked intro.\nPick size.\nFill name.\nLocked end."));
+2
View File
@@ -101,8 +101,10 @@ The Language dialog reads effective character formatting, including document def
| Date | Change | Summary |
|---|---|---|
| 2026-10-17 | trivial | #1327: Restrict Editing exceptions no longer let table, row or cell formatting change protected tables; formatting-only protection refuses Paste Formatting, table shading/borders/alignment/margins/text direction, table-style edits and locked table styles; recipient-list commands no longer rewrite merge-field results in a protected document. No gap row (enforcement fix); estimates unchanged |
| 2026-10-18 | trivial | #2445: pending Save As answers keep their originating document identity, so a replacement document is never written to an old picker destination; real Save-button and focused-editor shortcut controls verified |
| 2026-10-16 | trivial | #2445: interactive Save on import-only .doc/.dot paths uses the existing DOCX Save As flow; cancel and failed writes keep the draft, and Close waits for a successful save. Explicit/script saves still report unsupported legacy output |
| 2026-10-17 | trivial | #1327: Restrict Editing exceptions no longer let table, row or cell formatting change protected tables; formatting-only protection refuses Paste Formatting, table shading/borders/alignment/margins/text direction, table-style edits and locked table styles; recipient-list commands no longer rewrite merge-field results in a protected document. No gap row (enforcement fix); estimates unchanged |
| 2026-10-16 | trivial | #24: the Spike (Ctrl+F3, Ctrl+Shift+F3) |
| 2026-10-17 | trivial | Glossary/building blocks closed (DOCX glossary read, kept and written); `w:altChunk` and sub-documents remain |
| 2026-10-15 | trivial | #5 narrowed: Track Changes Options › Moves (Moved from / Moved to marks and colours) and Track moves (`w:doNotTrackMoves`) landed; drag-and-drop moves remain |