Files
wordcraft/crates/engine/src/cmd/protect.rs
T

689 lines
34 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Restrict Editing (Review › Protect, #412): editing restrictions (read only, comments, tracked
//! changes, filling in forms), formatting limited to a selection of styles, exceptions — ranges
//! everyone may still edit (`w:permStart w:edGrp="everyone"`) — and an optional password that
//! guards Stop Protection (ECMA-376 §17.15.1.29; the hash is in `wordcraft_docx`).
//!
//! Enforcement lives in [`crate::Session::run`]: [`check_before`] refuses commands the
//! restrictions forbid outright, and while exceptions exist [`outside_unchanged`] checks after the
//! command that nothing outside them changed (the command is rolled back otherwise).
use serde_json::{Value, json};
use std::collections::HashSet;
use wordcraft_doc::para::{InlineObject, OBJ};
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};
/// The editing restrictions `w:edit` names.
const MODES: [&str; 5] = ["none", "readOnly", "comments", "trackedChanges", "forms"];
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("review.restrict", "Restrict Editing", "Review › Protect", restrict).params(
r#"{"mode"?: "none|readOnly|comments|trackedChanges|forms" (start enforcing; "none" stops), "formatting"?: bool (limit formatting to unlocked styles), "lockedStyles"?: [style id or name] (the styles that can't be applied; every other style is unlocked), "password"?: string (set when enforcing; required to stop a password-protected document), "stop"?: bool, "exception"?: "add" (the selection stays editable for everyone) | "remove" (exceptions touching the selection) | "removeAll"} → state"#,
),
]
}
/// The protection state, as `review.restrict` returns it and the Restrict Editing pane shows it.
pub fn state(s: &Session) -> Value {
let st = &s.doc.settings;
let regions: Vec<Value> =
s.doc.perm_ranges().iter().filter(|r| editable_by(r, &s.author)).map(|r| json!({"start": r.start, "end": r.end})).collect();
json!({
"protection": st.protection,
"formatting": st.protect_formatting,
"password": !st.protect_hash.is_empty(),
"lockedStyles": s.doc.styles.styles.iter().filter(|x| x.locked).map(|x| x.id.clone()).collect::<Vec<_>>(),
"exceptions": regions.len(),
"regions": regions,
"formFields": s.doc.fillable_controls(),
})
}
/// Is the document's editing or formatting restricted?
pub fn enforced(doc: &Document) -> bool {
doc.settings.protection.is_some()
}
/// Is the document's editing restricted? The formatting-only protection stores the mode "none"
/// (`w:documentProtection w:edit="none"` with `w:formatting="1"`), so it doesn't count here: it
/// limits the styles, not the editing.
pub fn editing_restricted(doc: &Document) -> bool {
doc.settings.protection.as_deref().is_some_and(|m| m != "none")
}
/// May `author` edit inside the range?
fn editable_by(r: &PermRange, author: &str) -> bool {
r.everyone() || (!r.editor.is_empty() && r.editor.eq_ignore_ascii_case(author.trim()))
}
fn restrict(s: &mut Session, v: &Value) -> CmdResult {
if let Some(e) = p::str(v, "exception") {
if enforced(&s.doc) {
return Err(CmdError::Disabled("stop protection before changing the exceptions".into()));
}
match e {
"add" => add_exception(s)?,
"remove" => remove_exceptions(s, false)?,
"removeAll" => remove_exceptions(s, true)?,
_ => return Err(CmdError::Params("`exception` is \"add\", \"remove\" or \"removeAll\"".into())),
}
return Ok(state(s));
}
if let Some(list) = v.get("lockedStyles") {
let Some(list) = list.as_array() else { return Err(CmdError::Params("`lockedStyles` is a list of style ids or names".into())) };
if enforced(&s.doc) {
return Err(CmdError::Disabled("stop protection before changing the formatting restrictions".into()));
}
let names: Vec<&str> = list.iter().filter_map(Value::as_str).take(10_000).collect();
for st in &mut s.doc.styles.styles {
st.locked = names.iter().any(|n| *n == st.id || n.eq_ignore_ascii_case(&st.name));
}
}
let mode = p::str(v, "mode");
if let Some(m) = mode
&& !MODES.contains(&m)
{
return Err(CmdError::Params(format!("unknown `mode` {m:?}: none, readOnly, comments, trackedChanges or forms")));
}
let formatting = p::bool(v, "formatting");
let explicit_stop = p::bool(v, "stop") == Some(true);
// Stopping the protection is explicit: the "none" mode is the shorthand for it, but on a
// protected document only `{"stop": true}` stops it — a command that merely names a mode
// can't strip a user's protection (Word asks for Stop Protection first).
if mode == Some("none") && formatting != Some(true) && !explicit_stop && enforced(&s.doc) {
return Err(CmdError::Disabled("the document is protected: stop protection first (\"stop\": true)".into()));
}
let stop = explicit_stop || (mode == Some("none") && formatting != Some(true));
if stop {
return stop_protection(s, p::str(v, "password").unwrap_or(""));
}
if mode.is_none() && formatting.is_none() {
// Only the locked styles changed (or nothing): report the state.
return Ok(state(s));
}
// The protection of a protected document doesn't change: Word asks for Stop Protection first.
// (The formatting-only protection isn't one: it limits the styles, not the editing — but a
// password on it still guards it, so it can't be replaced either.)
if editing_restricted(&s.doc) || !s.doc.settings.protect_hash.is_empty() {
let why = if s.doc.settings.protect_hash.is_empty() {
"the document is protected: stop protection first"
} else {
"the document is protected with a password: stop protection first"
};
return Err(CmdError::Disabled(why.into()));
}
let formatting = formatting.unwrap_or(false);
let mode = mode.unwrap_or("none");
if mode == "none" && !formatting {
return stop_protection(s, "");
}
let hash = match p::str(v, "password").filter(|p| !p.is_empty()) {
Some(pw) => wordcraft_docx::protection_hash(pw, wordcraft_docx::DEFAULT_SPIN_COUNT).map_err(|e| CmdError::Params(e.to_string()))?,
None => Vec::new(),
};
let st = &mut s.doc.settings;
st.protection = Some(mode.to_string());
st.protect_formatting = formatting;
st.protect_hash = hash;
if mode == "trackedChanges" {
st.track_changes = true;
}
Ok(state(s))
}
/// The error Stop Protection reports when the document's password hash uses an algorithm or form
/// WordCraft can't check; the protection stays on.
pub const UNSUPPORTED_PASSWORD: &str = "This password type isn't supported";
/// Stop Protection: with the document's password when it has one. The password is checked like
/// Word checks it — the current hash form and the legacy one (ECMA-376 Part 4 §14.8.1); a hash
/// WordCraft can't check refuses ([`UNSUPPORTED_PASSWORD`]) rather than unlocking.
fn stop_protection(s: &mut Session, password: &str) -> CmdResult {
if enforced(&s.doc) {
use wordcraft_docx::PasswordCheck;
match wordcraft_docx::check_protection_password(&s.doc.settings.protect_hash, password) {
PasswordCheck::NoPassword | PasswordCheck::Correct => {}
PasswordCheck::Incorrect if password.is_empty() => {
return Err(CmdError::Params("type the document's password to stop protection".into()));
}
PasswordCheck::Incorrect => return Err(CmdError::Params("the password is incorrect".into())),
PasswordCheck::Unsupported => return Err(CmdError::Disabled(UNSUPPORTED_PASSWORD.into())),
}
}
let st = &mut s.doc.settings;
st.protection = None;
st.protect_formatting = false;
st.protect_hash.clear();
Ok(state(s))
}
/// Make the selection an exception everyone may edit.
fn add_exception(s: &mut Session) -> Result<(), CmdError> {
let (a, b) = s.sel.ordered();
if a == b || a.story != b.story {
return Err(CmdError::Params("select the text that stays editable".into()));
}
let id = s.doc.perm_ranges().iter().map(|r| r.id).max().map_or(0, |m| m.saturating_add(1));
let props = CharProps::default();
// The end first: inserting it can't move the start.
s.doc.insert_object(&b, InlineObject::PermEnd { id }, &props)?;
let after_start = s.doc.insert_object(&a, InlineObject::PermStart { id, group: "everyone".into(), editor: String::new() }, &props)?;
let mut end = b.clone();
if end.path == a.path && end.story == a.story {
end.off += OBJ.len_utf8();
}
s.sel = crate::Selection { anchor: after_start, focus: end };
Ok(())
}
/// Remove the exceptions that touch the selection (or all of them).
fn remove_exceptions(s: &mut Session, all: bool) -> Result<(), CmdError> {
let (a, b) = s.sel.ordered();
let ids: Vec<u32> =
s.doc.perm_ranges().iter().filter(|r| all || (r.start.story == a.story && r.start <= b && a <= r.end)).map(|r| r.id).collect();
if ids.is_empty() {
return Ok(());
}
let stories: Vec<StoryRef> = std::iter::once(StoryRef::Body).chain(s.doc.parts.keys().map(|k| StoryRef::Part(*k))).collect();
for story in stories {
for path in s.doc.para_paths(story) {
let Some(p) = s.doc.para(story, &path) else { continue };
let marks: Vec<usize> = p
.object_offsets()
.into_iter()
.filter(
|off| matches!(p.object_at(*off), Some(InlineObject::PermStart { id, .. } | InlineObject::PermEnd { id }) if ids.contains(id)),
)
.collect();
if marks.is_empty() {
continue;
}
let para = s.doc.para_mut(story, &path)?;
for off in marks.into_iter().rev() {
para.delete(off, off + OBJ.len_utf8())?;
}
}
}
s.clamp_selection();
Ok(())
}
/// The Developer tab's commands that insert a content control.
const CONTROL_INSERTS: [&str; 9] = [
"developer.richText",
"developer.plainText",
"developer.picture",
"developer.buildingBlock",
"developer.checkBox",
"developer.comboBox",
"developer.dropDown",
"developer.datePicker",
"developer.repeatingSection",
];
/// Commands always allowed in a protected document: protection itself, undo, and the file
/// commands that don't change the open document. A `file.*` command that does change it is
/// checked like any other, so the protection holds for it too: the privacy options and the
/// properties are document settings, and the inspector removes content (#1327 follow-up).
fn always_allowed(id: &str, params: &Value) -> bool {
if id == "review.restrict" || id == "edit.undo" || id == "edit.redo" {
return true;
}
if !id.starts_with("file.") {
return false;
}
match id {
"file.privacyOptions" | "file.properties" => false,
// The user name says who may edit the document and who the revisions belong to: it is
// checked like any other change, not waved through.
"file.setAuthor" => false,
// The inspector reads the document; its removals change it.
"file.inspect" => params.get("remove").and_then(Value::as_array).is_none_or(|a| a.is_empty()),
_ => true,
}
}
/// The user name (`file.setAuthor`) is the identity a named exception range is checked against,
/// and the author recorded on new revisions and comments: it can't change while the document is
/// protected. Word lets the user name change under protection (it is a preference there); this
/// project doesn't, because the name says who may edit what (#1327 follow-up).
pub(crate) fn check_user_name(s: &Session) -> Result<(), CmdError> {
if enforced(&s.doc) {
return Err(CmdError::Disabled("file.setAuthor: stop protection before changing the user name".into()));
}
Ok(())
}
/// A version restore replaces the whole document, including the content the protection kept
/// read-only, so it needs the protection stopped first — and the password with it, when the
/// document has one. (`file.versions` is a pure command, so `check_before` doesn't see it; the
/// command checks this itself — see [`check_version_restore`].)
pub(crate) fn check_version_restore(s: &Session) -> Result<(), CmdError> {
if enforced(&s.doc) {
return Err(CmdError::Disabled("file.versions: stop protection before restoring a version".into()));
}
Ok(())
}
/// What decides a protected document's outright refusals: whether the current author may edit
/// any exception (`w:permStart`), and how many content controls take entries. Both walk the
/// whole document.
#[derive(Clone, Copy, Debug)]
pub(crate) struct Facts {
pub exceptions: bool,
pub fillable: usize,
}
impl Facts {
pub(crate) fn of(s: &Session) -> Facts {
Facts { exceptions: s.doc.perm_ranges().iter().any(|r| editable_by(r, &s.author)), fillable: s.doc.fillable_controls() }
}
}
/// Why protection refuses command `id` whatever its params and the selection, so the ribbon greys
/// it out ([`Session::why_disabled`], with [`Facts`] cached per document revision) and
/// [`check_before`] refuses it. Observed in Word for Mac: with Filling in forms enforced, the
/// Developer tab's control buttons and Properties are greyed out and a field's shortcut menu
/// offers no Remove Content Control. The form's fields stay as they are; only their content is
/// filled in. A document that is read only (or open to comments only) with no exception the
/// author may edit takes no other edits at all.
pub(crate) fn greyed_out(s: &Session, id: &str, facts: impl FnOnce() -> Facts) -> Option<&'static str> {
let mode = s.doc.settings.protection.as_deref()?;
if always_allowed(id, &Value::Null) {
return None;
}
if mode == "forms" {
match id {
"developer.removeControl" => return Some("stop protection before removing a content control"),
"developer.properties" => return Some("stop protection before changing a content control's properties"),
_ if CONTROL_INSERTS.contains(&id) => return Some("stop protection before inserting a content control"),
_ => {}
}
}
let comment_ok = id.starts_with("review.") && (id.contains("Comment") || id == "review.reply");
let tracked = matches!(id, "review.accept" | "review.reject" | "review.acceptAll" | "review.rejectAll" | "review.trackChanges");
match mode {
"readOnly" => (!facts().exceptions).then_some("the document is protected (read only)"),
"forms" => (facts().fillable == 0).then_some("the document is protected (read only)"),
"comments" if !comment_ok => (!facts().exceptions).then_some("only comments are allowed in this document"),
"trackedChanges" if tracked => Some("changes to this document are tracked"),
_ => None,
}
}
/// What a protected document lets command `id` do before it runs: `Err` refuses it; `Ok(true)`
/// lets it run as long as it only changes the exceptions (checked afterwards with
/// [`outside_unchanged`]).
pub(crate) fn check_before(s: &Session, id: &str, params: &Value) -> Result<bool, CmdError> {
let Some(mode) = s.doc.settings.protection.as_deref() else { return Ok(false) };
if always_allowed(id, params) {
return Ok(false);
}
// The user name says who may edit the document, and who the new revisions and comments
// belong to: it can't change while the document is protected. (`file.setAuthor` is a pure
// command, so `check_before` doesn't see it; the command checks this itself — see
// [`check_user_name`].)
if id == "file.setAuthor" {
check_user_name(s)?;
}
// Word blocks content-control property edits until Stop Protection: changing a control's
// lock flags would reshape what forms mode lets through. The param's presence is the
// trigger — absent, the command keeps the current lock (as `check_formatting` inspects
// params). In other modes, other properties (title, tag, placeholder…) stay editable; forms
// mode refuses every property change (below).
if id == "developer.properties" && (params.get("lockEdit").is_some() || params.get("lockDelete").is_some()) {
return Err(CmdError::Disabled(format!("{id}: stop protection before changing a content control's locks")));
}
// The document's properties and its privacy options are document-wide state, and they aren't
// tracked: a document that tracks changes would take them with no revision to show for it, and
// the revision trail would say nothing about them. They're refused while the document is
// protected, as the content control's properties are (#3278 follow-up).
// The inspector's removals take content out of the document — the comments, the revisions,
// the properties, the hidden text, the headers — so they are refused in every mode too: under
// tracked changes they would change the document with no revision to show for it, and under
// the other modes the document may not change at all. (An inspection without removals only
// reads the document, and is let through by `always_allowed`.)
if enforced(&s.doc) {
match id {
"file.properties" => return Err(CmdError::Disabled(format!("{id}: stop protection before changing the document's properties"))),
"file.privacyOptions" => return Err(CmdError::Disabled(format!("{id}: stop protection before changing the privacy options"))),
"file.inspect" => return Err(CmdError::Disabled(format!("{id}: stop protection before removing content with the inspector"))),
_ => {}
}
}
if let Some(why) = greyed_out(s, id, || Facts::of(s)) {
return Err(CmdError::Disabled(format!("{id}: {why}")));
}
if s.doc.settings.protect_formatting {
check_formatting(s, id, params)?;
}
// Not refused outright: read only and comments mode have an exception this author may edit,
// forms mode has fillable controls. Comments are always allowed in comments mode.
let comment_ok = id.starts_with("review.") && (id.contains("Comment") || id == "review.reply");
match mode {
"comments" if comment_ok => Ok(false),
"readOnly" | "forms" | "comments" => Ok(true),
_ => Ok(false),
}
}
/// 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" | "table.style" => p::str(params, "style"),
"para.normal" => Some("Normal"),
"para.heading1" => Some("Heading1"),
"para.heading2" => Some("Heading2"),
"para.heading3" => Some("Heading3"),
_ => None,
};
if let Some(name) = style {
let locked = s.doc.styles.styles.iter().any(|st| st.locked && (st.id == name || st.name.eq_ignore_ascii_case(name)));
return if locked { Err(CmdError::Disabled(format!("{id}: the style {name:?} is locked in this document"))) } else { Ok(()) };
}
let direct = (id.starts_with("format.") && !matches!(id, "format.changeCase" | "format.state" | "format.fontDialog"))
|| (id.starts_with("para.") && !matches!(id, "para.sort" | "para.dialog"))
|| id.starts_with("styles.create")
|| id.starts_with("styles.modify")
|| id.starts_with("styles.update")
|| 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"
// Copies style definitions in from another file, locked flags included (#3692).
| "styles.copyFrom"
)
// 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")));
}
Ok(())
}
/// Which parts of a protected document stay editable: the `w:permStart` ranges `author` may
/// edit (readOnly, comments), or — filling in forms — the interiors of the content controls
/// whose `w:lock` allows editing them.
enum HoleMode {
Perms(String),
Controls,
}
/// 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 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), the document's settings and its metadata.
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()) };
let (b, a) = (&before.styles, &after.styles);
before.last_section == after.last_section
&& sections(before) == sections(after)
&& b.default_chr == a.default_chr
&& b.default_para == a.default_para
&& b.styles.iter().all(|st| a.get(&st.id) == Some(st))
&& before.settings == after.settings
&& before.core == after.core
&& before.custom_props == after.custom_props
&& comments_unchanged(before, after)
&& lists_unchanged(before, after)
&& sources_unchanged(before, after)
&& outside(before, &mode) == outside(after, &mode)
}
/// The comments' resolved flags: a comment's text is a part, and the parts are compared with the
/// rest of the document; the flag is the comment's own state.
fn comments_unchanged(before: &Document, after: &Document) -> bool {
before.comments.iter().all(|(id, c)| after.comments.get(id).is_some_and(|a| a.resolved == c.resolved))
}
/// The list definitions: the ones the document had must be unchanged — a list's format reaches
/// every paragraph that uses it — but a new one may be added, like a new style: applying a list
/// inside an editable region adds its definition.
fn lists_unchanged(before: &Document, after: &Document) -> bool {
before.numbering.abstracts.iter().all(|x| after.numbering.abstracts.contains(x))
&& before.numbering.nums.iter().all(|x| after.numbering.nums.contains(x))
}
/// The bibliography sources: the ones the document had must be unchanged; a new one may be added
/// (a citation inserted inside an editable region).
fn sources_unchanged(before: &Document, after: &Document) -> bool {
before.sources.iter().all(|x| after.sources.contains(x))
}
/// The section properties paragraphs end, in order.
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, 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
/// the doc crate's [`Document::control_ranges`], which pairs per block list (never across a
/// table-cell boundary) and skips controls wrapped around tables. These are the same markers
/// [`outside`] walks and [`Document::fillable_controls`] counts, so the walk and the count
/// agree. A start that never closes (or an end nothing opened) is an orphan, not a hole: it
/// stays ordinary protected content, and the text after it stays protected.
fn paired_control_markers(doc: &Document, story: StoryRef) -> HashSet<(Path, usize)> {
doc.control_ranges(story).into_iter().flat_map(|r| [(r.start.path, r.start.off), (r.end.path, r.end.off)]).collect()
}
/// Everything outside the editable regions, in order.
fn outside(doc: &Document, mode: &HoleMode) -> Vec<Piece> {
let editable: Vec<u32> = match mode {
HoleMode::Perms(author) => doc.perm_ranges().iter().filter(|r| editable_by(r, author)).map(|r| r.id).collect(),
HoleMode::Controls => Vec::new(),
};
let mut out = Vec::new();
let stories = std::iter::once(StoryRef::Body).chain(doc.parts.keys().map(|k| StoryRef::Part(*k)));
for story in stories {
let mut open: Vec<u32> = Vec::new();
let mut fillable: Vec<bool> = Vec::new();
// In forms mode, only markers that pair open and close holes (orphans stay content).
let paired = if matches!(mode, HoleMode::Controls) { paired_control_markers(doc, story) } else { HashSet::new() };
// The perm range or fillable control the walk is inside, innermost last.
let in_hole = |open: &[u32], fillable: &[bool]| match mode {
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 };
let mut piece = String::new();
for (i, c) in text.char_indices() {
if c == OBJ {
let obj = p.objects.get(k);
k += 1;
// A marker that opens or closes a hole moves the walk's stack instead.
let mut boundary = false;
match mode {
HoleMode::Perms(_) => match obj {
Some(InlineObject::PermStart { id, .. }) if editable.contains(id) => {
flush(&mut out, &mut piece, props, in_hole(&open, &fillable));
open.push(*id);
boundary = true;
}
Some(InlineObject::PermEnd { id }) if editable.contains(id) => {
flush(&mut out, &mut piece, props, in_hole(&open, &fillable));
if let Some(j) = open.iter().rposition(|x| x == id) {
open.remove(j);
}
boundary = true;
}
_ => {}
},
HoleMode::Controls => match obj {
Some(InlineObject::ControlStart { control }) if paired.contains(&(path.clone(), r.start + i)) => {
flush(&mut out, &mut piece, props, in_hole(&open, &fillable));
fillable.push(control.lock.can_edit());
boundary = true;
}
Some(InlineObject::ControlEnd) if paired.contains(&(path.clone(), r.start + i)) => {
flush(&mut out, &mut piece, props, in_hole(&open, &fillable));
fillable.pop();
boundary = true;
}
_ => {}
},
}
if !boundary && !in_hole(&open, &fillable) {
flush(&mut out, &mut piece, props, false);
out.push(Piece::Object(obj.cloned()));
}
continue;
}
if !in_hole(&open, &fillable) {
piece.push(c);
}
}
flush(&mut out, &mut piece, props, in_hole(&open, &fillable));
}
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()));
}
piece.clear();
}
/// Did undo or redo take protection away (it may only be lifted with Stop Protection)?
pub(crate) fn protection_lifted(before: &Document, after: &Document) -> bool {
let (b, a) = (&before.settings, &after.settings);
b.protection.is_some() && (a.protection != b.protection || a.protect_formatting != b.protect_formatting || a.protect_hash != b.protect_hash)
}
/// Where typing at the caret `p` goes in a document that is read only or open to comments only: a
/// caret right before the start of an exception this author may edit goes inside it, and one right
/// after its end goes back inside it. The markers take no room, so the start of a line that an
/// exception starts, and the end of a line that one ends, are those carets.
pub(crate) fn typing_pos(s: &Session, p: &Pos) -> Pos {
if !matches!(s.doc.settings.protection.as_deref(), Some("readOnly" | "comments")) {
return p.clone();
}
let Some(para) = s.doc.para_at(p) else { return p.clone() };
if para.objects.is_empty() {
return p.clone();
}
let editable: Vec<u32> = s.doc.perm_ranges().iter().filter(|r| editable_by(r, &s.author)).map(|r| r.id).collect();
let is_start = |off: usize| matches!(para.object_at(off), Some(InlineObject::PermStart { id, .. }) if editable.contains(id));
let is_end = |off: usize| matches!(para.object_at(off), Some(InlineObject::PermEnd { id }) if editable.contains(id));
let w = OBJ.len_utf8();
let mut off = p.off;
if is_start(off) {
while is_start(off) {
off += w;
}
return Pos { off, ..p.clone() };
}
while off >= w && is_end(off - w) {
off -= w;
}
Pos { off, ..p.clone() }
}
/// The first position in an editable range after `from` (wrapping round), for Find Next Region
/// I Can Edit; with it, the range's end. Filling in forms: the next fillable control's content.
pub fn next_region(s: &Session, from: &Pos) -> Option<(Pos, Pos)> {
if s.doc.settings.protection.as_deref() == Some("forms") {
let stories = std::iter::once(StoryRef::Body).chain(s.doc.parts.keys().map(|k| StoryRef::Part(*k)));
let mut ranges: Vec<(Pos, Pos)> =
stories.flat_map(|story| s.doc.control_ranges(story)).filter(|r| r.control.lock.can_edit()).map(|r| (r.content_start(), r.end)).collect();
ranges.sort_by(|a, b| a.0.cmp(&b.0));
let next = ranges.iter().find(|r| r.0 > *from).or_else(|| ranges.first())?;
return Some((next.0.clone(), next.1.clone()));
}
let ranges: Vec<PermRange> = s.doc.perm_ranges().into_iter().filter(|r| editable_by(r, &s.author)).collect();
let next = ranges.iter().find(|r| r.start > *from).or_else(|| ranges.first())?;
Some((next.start.clone(), next.end.clone()))
}
#[cfg(test)]
mod tests;