From 74052ac75219a0dd146059b4556fc511d2a4e726 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sun, 13 Sep 2026 09:04:21 +0200 Subject: [PATCH 1/3] feat(html): a host states how far an edit may reach `HtmlConfig::editing_scope` narrows the document view's editor to one run of text (`run`) or keeps the whole editor (`document`, the default). The body states it as `data-odr-editing-scope`, and the editor refuses an edit past it with the new `ErrorCode::edit_out_of_scope` (1010, `outOfScope`). The core has no edition, so `Document::is_editable` stays an engine fact and the scope is the one seam a host states its policy in. `Document::edit` is unchanged, so the gate refuses the edit and never the save. Bound in jni, apple, wasm and python. Decision 14 in `editing.md`. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QNQ2GtyAXXWGvnKBtwTEhQ --- CHANGELOG.md | 6 ++ apple/include/OdrCoreObjC/ODRHtml.h | 12 +++ apple/src/ODRHtml.mm | 5 ++ apple/tests/OdrCoreTests.swift | 17 +++++ docs/design/editing.md | 36 +++++++++ jni/CMakeLists.txt | 1 + .../app/opendocument/core/HtmlConfig.java | 2 + .../opendocument/core/HtmlEditingScope.java | 14 ++++ jni/src/jni_style.cpp | 11 +++ jni/tests/app/opendocument/core/HtmlTest.java | 21 ++++++ python/src/bind_html.cpp | 5 ++ python/tests/test_html.py | 19 +++++ src/odr/error_code.cpp | 3 +- src/odr/error_code.hpp | 2 + src/odr/html.hpp | 11 +++ src/odr/internal/html/common.cpp | 10 +++ src/odr/internal/html/common.hpp | 3 + src/odr/internal/html/document.cpp | 2 + src/odr/internal/html/frontend/document.js | 25 +++++++ src/odr/internal/html/frontend/editing.js | 8 ++ test/browser/text/README.md | 4 + test/browser/text/tests.html | 73 ++++++++++++++++++- test/src/error_code_test.cpp | 1 + test/src/html_test.cpp | 21 ++++++ wasm/README.md | 3 + wasm/js/index.d.ts | 3 + wasm/src/wasm_core.cpp | 3 + wasm/src/wasm_html.cpp | 1 + wasm/tests/enums.test.mjs | 2 + 29 files changed, 322 insertions(+), 2 deletions(-) create mode 100644 jni/java/app/opendocument/core/HtmlEditingScope.java diff --git a/CHANGELOG.md b/CHANGELOG.md index e413b8e3c..a770b1c35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,12 @@ The release run heads these entries with the version and opens a fresh ## Unreleased +- `HtmlConfig::editing_scope` narrows the document view's editor to one run of + text at a time (`HtmlEditingScope::run`); the default, `document`, keeps the + whole editor. The page states it as `data-odr-editing-scope` and refuses an + edit past it with the new `ErrorCode::edit_out_of_scope` (1010, `outOfScope`) + through `odr.onEditRefused`. Bound in jni, apple, wasm and python. + - Writing a cell of an `.ods` takes the cached result of every formula reading it away, keeping the formula itself, so the saved file states no number that is now wrong. Such a cell renders empty until a reader computes it. diff --git a/apple/include/OdrCoreObjC/ODRHtml.h b/apple/include/OdrCoreObjC/ODRHtml.h index 86386781f..fd84bf16b 100644 --- a/apple/include/OdrCoreObjC/ODRHtml.h +++ b/apple/include/OdrCoreObjC/ODRHtml.h @@ -51,6 +51,15 @@ typedef NS_ENUM(NSInteger, ODRHtmlViewportMode) { ODRHtmlViewportModeFitWidthByView, } NS_SWIFT_NAME(HtmlViewportMode); +/// How far an edit in a document view may reach. Host policy, not an engine +/// fact. +typedef NS_ENUM(NSInteger, ODRHtmlEditingScope) { + /// Inside one run of text: `setText` only. + ODRHtmlEditingScopeRun = 0, + /// Across runs and paragraphs: every operation. + ODRHtmlEditingScopeDocument, +} NS_SWIFT_NAME(HtmlEditingScope); + /// How text is emitted in PDF→HTML output. typedef NS_ENUM(NSInteger, ODRPdfTextMode) { /// A visual layer plus a transparent selection layer, like pdf.js. @@ -83,6 +92,9 @@ NS_SWIFT_NAME(HtmlConfig) @property(nonatomic) BOOL relativeResourcePaths; @property(nonatomic) BOOL editable; +/// The scope the document view's editor offers; the page refuses the rest +/// with `ODRErrorCode` 1010, `outOfScope`. +@property(nonatomic) ODRHtmlEditingScope editingScope; /// Whether the view's scripts take the keys that move the selection. @property(nonatomic) BOOL keyboardNavigation; /// Whether the view's scripts take the editing chords: undo and redo. diff --git a/apple/src/ODRHtml.mm b/apple/src/ODRHtml.mm index 4a6bcac45..b89053d90 100644 --- a/apple/src/ODRHtml.mm +++ b/apple/src/ODRHtml.mm @@ -39,6 +39,9 @@ ODR_SAME_ENUM(ODRHtmlViewportModeFitWidthByView, odr::HtmlViewportMode::fit_width_by_view); +ODR_SAME_ENUM(ODRHtmlEditingScopeRun, odr::HtmlEditingScope::run); +ODR_SAME_ENUM(ODRHtmlEditingScopeDocument, odr::HtmlEditingScope::document); + ODR_SAME_ENUM(ODRPdfTextModeDualLayer, odr::PdfTextMode::dual_layer); ODR_SAME_ENUM(ODRPdfTextModeSingleLayer, odr::PdfTextMode::single_layer); @@ -89,6 +92,7 @@ - (instancetype)initWithNativeConfig:(const odr::HtmlConfig &)config { _resourcePath = to_nsstring(config.resource_path); _relativeResourcePaths = config.relative_resource_paths ? YES : NO; _editable = config.editable ? YES : NO; + _editingScope = static_cast(config.editing_scope); _keyboardNavigation = config.keyboard_navigation ? YES : NO; _keyboardShortcuts = config.keyboard_shortcuts ? YES : NO; _textDocumentMargin = config.text_document_margin ? YES : NO; @@ -158,6 +162,7 @@ - (instancetype)initWithNativeConfig:(const odr::HtmlConfig &)config { } config.relative_resource_paths = _relativeResourcePaths == YES; config.editable = _editable == YES; + config.editing_scope = static_cast(_editingScope); config.keyboard_navigation = _keyboardNavigation == YES; config.keyboard_shortcuts = _keyboardShortcuts == YES; config.text_document_margin = _textDocumentMargin == YES; diff --git a/apple/tests/OdrCoreTests.swift b/apple/tests/OdrCoreTests.swift index 5cf15e5b3..bdc49ed32 100644 --- a/apple/tests/OdrCoreTests.swift +++ b/apple/tests/OdrCoreTests.swift @@ -181,6 +181,23 @@ final class HtmlTests: XCTestCase { XCTAssertTrue(html.contains("` (decision 12), so the scope +is one more attribute there, `data-odr-editing-scope`. + +**Why the editor reads it per edit, not once:** a host that widens the scope +mid-session - a purchase went through - sets the attribute and keeps the page. + +**Why the refusal is its own code:** the host maps 1010 to an explanation of +what the wider scope offers, where every other code is a shrug. A delete at a +run's edge was refused with `range` before the full editor existed, so nothing +a reader could do went away with the scope; only the code changed. + +**Why replay does not check it:** the page cannot produce an operation past +its scope, and a check in `Document::edit` would refuse the *save*, after the +reader typed. A second guard can take a scope argument later. + ## What landed, and what did not The plan this document carried ran in five steps, and the first four are in. diff --git a/jni/CMakeLists.txt b/jni/CMakeLists.txt index 707b0f3b4..5006aecc3 100644 --- a/jni/CMakeLists.txt +++ b/jni/CMakeLists.txt @@ -111,6 +111,7 @@ add_jar(odr_java "java/app/opendocument/core/Html.java" "java/app/opendocument/core/HtmlColorScheme.java" "java/app/opendocument/core/HtmlConfig.java" + "java/app/opendocument/core/HtmlEditingScope.java" "java/app/opendocument/core/HtmlPage.java" "java/app/opendocument/core/HtmlResource.java" "java/app/opendocument/core/HtmlResourceType.java" diff --git a/jni/java/app/opendocument/core/HtmlConfig.java b/jni/java/app/opendocument/core/HtmlConfig.java index 45e90b41e..c78c88b20 100644 --- a/jni/java/app/opendocument/core/HtmlConfig.java +++ b/jni/java/app/opendocument/core/HtmlConfig.java @@ -20,6 +20,8 @@ public final class HtmlConfig { public boolean relativeResourcePaths = true; public boolean editable = false; + /** How far an edit in a document view may reach; the page refuses the rest. */ + public HtmlEditingScope editingScope = HtmlEditingScope.DOCUMENT; /** Whether the view's scripts take the keys that move the selection. */ public boolean keyboardNavigation = true; diff --git a/jni/java/app/opendocument/core/HtmlEditingScope.java b/jni/java/app/opendocument/core/HtmlEditingScope.java new file mode 100644 index 000000000..c352bfb4d --- /dev/null +++ b/jni/java/app/opendocument/core/HtmlEditingScope.java @@ -0,0 +1,14 @@ +package app.opendocument.core; + +/** Mirrors {@code odr::HtmlEditingScope}; constant order must match the C++ declaration. */ +public enum HtmlEditingScope { + RUN, DOCUMENT; + + static HtmlEditingScope fromNative(int code) { + return code < 0 ? null : values()[code]; + } + + int toNative() { + return ordinal(); + } +} diff --git a/jni/src/jni_style.cpp b/jni/src/jni_style.cpp index f5bfc18b1..aa5e9f6bb 100644 --- a/jni/src/jni_style.cpp +++ b/jni/src/jni_style.cpp @@ -453,6 +453,9 @@ jobject html_config_to_java(JNIEnv *env, const odr::HtmlConfig &config) { set_string("resourcePath", config.resource_path); set_boolean("relativeResourcePaths", config.relative_resource_paths); set_boolean("editable", config.editable); + set_object("editingScope", "Lapp/opendocument/core/HtmlEditingScope;", + enum_from_code(env, "app/opendocument/core/HtmlEditingScope", + static_cast(config.editing_scope))); set_boolean("keyboardNavigation", config.keyboard_navigation); set_boolean("keyboardShortcuts", config.keyboard_shortcuts); set_boolean("textDocumentMargin", config.text_document_margin); @@ -575,6 +578,14 @@ odr::HtmlConfig html_config_from_java(JNIEnv *env, jobject config) { } result.relative_resource_paths = get_boolean("relativeResourcePaths"); result.editable = get_boolean("editable"); + { + const jint code = enum_ordinal( + env, + get_object("editingScope", "Lapp/opendocument/core/HtmlEditingScope;")); + if (code >= 0) { + result.editing_scope = static_cast(code); + } + } result.keyboard_navigation = get_boolean("keyboardNavigation"); result.keyboard_shortcuts = get_boolean("keyboardShortcuts"); result.text_document_margin = get_boolean("textDocumentMargin"); diff --git a/jni/tests/app/opendocument/core/HtmlTest.java b/jni/tests/app/opendocument/core/HtmlTest.java index f23e9d622..1d7479443 100644 --- a/jni/tests/app/opendocument/core/HtmlTest.java +++ b/jni/tests/app/opendocument/core/HtmlTest.java @@ -33,6 +33,7 @@ void htmlConfigDefaults() { HtmlConfig config = new HtmlConfig(); assertTrue(config.embedImages); assertTrue(!config.editable); + assertEquals(HtmlEditingScope.DOCUMENT, config.editingScope); assertEquals(HtmlTableGridlines.SOFT, config.spreadsheetGridlines); assertEquals(HtmlViewportMode.AUTOMATIC, config.viewportMode); assertNull(config.spreadsheetViewportMode); @@ -60,6 +61,26 @@ void viewportConfigRoundTrips() throws IOException { assertEquals(Double.valueOf(1.5), readBack.initialZoom); } + /** Proves the scope crosses JNI; the C++ suite covers the rest. */ + @Test + void editingScopeReachesTheHtml() throws IOException { + // the attribute, not the name: the script names it too + assertTrue(!renderOdt(new HtmlConfig()).contains("data-odr-editing-scope=\"")); + + HtmlConfig editable = new HtmlConfig(); + editable.editable = true; + assertTrue(renderOdt(editable).contains("data-odr-editing-scope=\"document\"")); + + HtmlConfig run = new HtmlConfig(); + run.editable = true; + run.editingScope = HtmlEditingScope.RUN; + assertTrue(renderOdt(run).contains("data-odr-editing-scope=\"run\"")); + + DecodedFile file = Odr.open(TestFiles.odtFile(tempDir).toString()); + HtmlConfig readBack = Html.translate(file, run).config(); + assertEquals(HtmlEditingScope.RUN, readBack.editingScope); + } + /** The C++ suite covers where the floor lands; this only proves it crosses JNI. */ @Test void minContentMarginReachesTheHtml() throws IOException { diff --git a/python/src/bind_html.cpp b/python/src/bind_html.cpp index 01b6b5444..8291ac8ca 100644 --- a/python/src/bind_html.cpp +++ b/python/src/bind_html.cpp @@ -42,6 +42,10 @@ void odr_python::bind_html(py::module_ &m) { .value("none", odr::HtmlViewportMode::none) .value("fit_width_by_view", odr::HtmlViewportMode::fit_width_by_view); + py::enum_(m, "HtmlEditingScope") + .value("run", odr::HtmlEditingScope::run) + .value("document", odr::HtmlEditingScope::document); + py::enum_(m, "PdfTextMode") .value("dual_layer", odr::PdfTextMode::dual_layer) .value("single_layer", odr::PdfTextMode::single_layer); @@ -79,6 +83,7 @@ void odr_python::bind_html(py::module_ &m) { .def_readwrite("relative_resource_paths", &odr::HtmlConfig::relative_resource_paths) .def_readwrite("editable", &odr::HtmlConfig::editable) + .def_readwrite("editing_scope", &odr::HtmlConfig::editing_scope) .def_readwrite("keyboard_navigation", &odr::HtmlConfig::keyboard_navigation) .def_readwrite("keyboard_shortcuts", &odr::HtmlConfig::keyboard_shortcuts) diff --git a/python/tests/test_html.py b/python/tests/test_html.py index fe13ee004..c76c4f42f 100644 --- a/python/tests/test_html.py +++ b/python/tests/test_html.py @@ -15,6 +15,7 @@ def test_html_config_defaults(): config = pyodr.HtmlConfig() assert config.embed_images assert not config.editable + assert config.editing_scope == pyodr.HtmlEditingScope.document assert config.keyboard_navigation assert config.keyboard_shortcuts assert config.spreadsheet_gridlines == pyodr.HtmlTableGridlines.soft @@ -111,6 +112,24 @@ def render(config): assert '' in render(raw) +def test_editing_scope_reaches_the_html(odt_path): + # The C++ suite covers what the page refuses; this only proves the scope + # crosses the binding. + def render(config): + file = pyodr.open(str(odt_path)) + service = pyodr.html.translate(file, config) + content, _ = service.list_views()[0].write_html() + return content + + # the attribute, not the name: the script names it too + assert 'data-odr-editing-scope="' not in render(pyodr.HtmlConfig()) + + config = pyodr.HtmlConfig() + config.editable = True + config.editing_scope = pyodr.HtmlEditingScope.run + assert 'data-odr-editing-scope="run"' in render(config) + + def test_min_content_margin_reaches_the_html(odt_path): # The C++ suite covers where the floor lands; this only proves it crosses # the binding, unset sides and all. diff --git a/src/odr/error_code.cpp b/src/odr/error_code.cpp index 6bcf736cf..52dbba58a 100644 --- a/src/odr/error_code.cpp +++ b/src/odr/error_code.cpp @@ -13,7 +13,7 @@ struct Row final { using odr::ErrorCode; -constexpr std::array rows{{ +constexpr std::array rows{{ {ErrorCode::unknown, "Unknown"}, {ErrorCode::unsupported_operation, "UnsupportedOperation"}, {ErrorCode::file_not_found, "FileNotFound"}, @@ -77,6 +77,7 @@ constexpr std::array rows{{ {ErrorCode::edit_unsupported, "unsupportedEdit"}, {ErrorCode::edit_range, "range"}, {ErrorCode::edit_unnameable, "unnameableEdit"}, + {ErrorCode::edit_out_of_scope, "outOfScope"}, }}; } // namespace diff --git a/src/odr/error_code.hpp b/src/odr/error_code.hpp index e62184080..88937e7f1 100644 --- a/src/odr/error_code.hpp +++ b/src/odr/error_code.hpp @@ -78,6 +78,8 @@ enum class ErrorCode : std::int32_t { edit_unsupported = 1007, edit_range = 1008, ///< The range reaches over a picture or a table. edit_unnameable = 1009, ///< The edit landed where no operation names it. + /// The edit reaches past @ref HtmlConfig::editing_scope. + edit_out_of_scope = 1010, }; /// @brief The code's name, as the bindings already spell it. diff --git a/src/odr/html.hpp b/src/odr/html.hpp index ecd05b135..08536a0ed 100644 --- a/src/odr/html.hpp +++ b/src/odr/html.hpp @@ -99,6 +99,13 @@ enum class HtmlViewportMode { fit_width_by_view, }; +/// How far an edit in a document view may reach. Host policy, not an engine +/// fact. +enum class HtmlEditingScope { + run, ///< inside one run of text: `setText` only + document, ///< across runs and paragraphs: every operation +}; + /// How text is emitted in PDF→HTML output. Neither mode needs JavaScript. enum class PdfTextMode { dual_layer, ///< a visual layer (paint order, embedded PUA glyphs) plus a @@ -135,6 +142,10 @@ struct HtmlConfig { /// edit operation names, and the editor script. The mode itself starts off - /// the host turns it on with `odr.editing.enable()`. bool editable{false}; + /// The scope the document view's editor offers; the page refuses the rest + /// with @ref ErrorCode::edit_out_of_scope. Sheet and plain-text views + /// ignore it. + HtmlEditingScope editing_scope{HtmlEditingScope::document}; /// Whether the view's scripts take the keys that move the selection: the /// arrows, Tab, Escape, and the keys that open an editor over it. diff --git a/src/odr/internal/html/common.cpp b/src/odr/internal/html/common.cpp index eebac278c..9ab3a189c 100644 --- a/src/odr/internal/html/common.cpp +++ b/src/odr/internal/html/common.cpp @@ -359,4 +359,14 @@ std::string html::keyboard_classes(const HtmlConfig &config) { return classes; } +std::string_view html::editing_scope_name(const HtmlEditingScope scope) { + switch (scope) { + case HtmlEditingScope::run: + return "run"; + case HtmlEditingScope::document: + default: + return "document"; + } +} + } // namespace odr::internal diff --git a/src/odr/internal/html/common.hpp b/src/odr/internal/html/common.hpp index 916badd59..6606639cc 100644 --- a/src/odr/internal/html/common.hpp +++ b/src/odr/internal/html/common.hpp @@ -160,4 +160,7 @@ std::string file_to_url(const abstract::File &file, /// The key classes a view's scripts may take; `editing.md` decision 12. [[nodiscard]] std::string keyboard_classes(const HtmlConfig &config); +/// The scope as the page states it; `editing.md` decision 14. +[[nodiscard]] std::string_view editing_scope_name(HtmlEditingScope scope); + } // namespace odr::internal::html diff --git a/src/odr/internal/html/document.cpp b/src/odr/internal/html/document.cpp index ee9ae9076..c1a99470c 100644 --- a/src/odr/internal/html/document.cpp +++ b/src/odr/internal/html/document.cpp @@ -231,6 +231,8 @@ void write_body_begin(const Document &document, const WritingState &state) { if (state.config().editable) { clb("data-odr-editable", state.document_editable() ? "true" : "readOnly"); + clb("data-odr-editing-scope", std::string(editing_scope_name( + state.config().editing_scope))); } // not an editing fact: a read-only sheet has a pin to clear clb("data-odr-keyboard", keyboard_classes(state.config())); diff --git a/src/odr/internal/html/frontend/document.js b/src/odr/internal/html/frontend/document.js index da333cd4f..355347998 100644 --- a/src/odr/internal/html/frontend/document.js +++ b/src/odr/internal/html/frontend/document.js @@ -796,6 +796,14 @@ }; } + /// In scope `run` an edit is one `setText`: it starts and ends in one run. + function outOfScope(at) { + return ( + odr.editing.scope() === "run" && + (at.start.run === null || at.start.run !== at.end.run) + ); + } + function refuse(event, reason, at) { if (event.cancelable) { event.preventDefault(); @@ -869,6 +877,10 @@ } if (type === "insertParagraph") { + if (odr.editing.scope() === "run") { + refuse(event, "outOfScope", at); + return; + } var split = splitAt(at); if (split === null) { refuse(event, "range", at); @@ -887,6 +899,14 @@ refuse(event, "unsupportedEdit", at); return; } + // several lines open paragraphs + if ( + outOfScope(at) || + (odr.editing.scope() === "run" && /[\r\n]/.test(pasted)) + ) { + refuse(event, "outOfScope", at); + return; + } if (!paste(at, pasted)) { refuse(event, "range", at); return; @@ -909,6 +929,11 @@ event.preventDefault(); return; } + // a delete at a run's edge reaches into the run before it + if (outOfScope(covering)) { + refuse(event, "outOfScope", at); + return; + } var caret = replaceRange(covering, text(event)); if (caret === null) { diff --git a/src/odr/internal/html/frontend/editing.js b/src/odr/internal/html/frontend/editing.js index 7a0512f6a..ab61ae10a 100644 --- a/src/odr/internal/html/frontend/editing.js +++ b/src/odr/internal/html/frontend/editing.js @@ -29,6 +29,7 @@ unsupportedEdit: "this kind of edit is not supported", range: "an edit cannot reach over a picture or a table", unnameableEdit: "an edit landed where no operation can name it", + outOfScope: "the edit reaches past what this page offers", }; /// Falls back to `readOnly` for a reason no script here states. @@ -136,6 +137,13 @@ isEditable: function () { return editable; }, + /// `run` or `document`, as `` states it. Read per edit, so a host + /// can widen it without a render. + scope: function () { + return body.getAttribute("data-odr-editing-scope") === "run" + ? "run" + : "document"; + }, /// Adds one format's editor. Only `operations` is required; `enable`, /// `disable`, `undo`, `redo`, `canUndo`, `canRedo` and `committed` default. diff --git a/test/browser/text/README.md b/test/browser/text/README.md index bf20c1287..00928f137 100644 --- a/test/browser/text/README.md +++ b/test/browser/text/README.md @@ -50,6 +50,10 @@ Why the checks look the way they do: `document_edit_test.cpp` replays the same shapes in C++, which is what keeps the two sides from drifting apart. +- **Scope `run` is checked on the same fixture.** `odr.editing.scope()` reads + `data-odr-editing-scope` off `` per edit, so the group sets the + attribute, checks what is refused with code 1010, and takes it off again. + **Scripted editing is not the editing a reader does, which is why no check uses `execCommand`.** Chrome's scripted path raises no cancelable `beforeinput`, so `execCommand("insertParagraph")` splits a paragraph without the editor ever diff --git a/test/browser/text/tests.html b/test/browser/text/tests.html index 508d49a97..b578205e2 100644 --- a/test/browser/text/tests.html +++ b/test/browser/text/tests.html @@ -160,11 +160,12 @@ /// said no. Both cancel the `beforeinput`, so the refusal channel is /// what tells them apart, not `defaultPrevented`. var prevented = null; - function input(type, data) { + function input(type, data, clipboard) { var before = refusals.length; var event = new InputEvent("beforeinput", { inputType: type, data: data === undefined ? null : data, + dataTransfer: clipboard === undefined ? null : clipboard, bubbles: true, cancelable: true, }); @@ -498,6 +499,76 @@ tail.querySelector("x-s[style]") !== null ); + // ---------------------------------------------------- scope run + + reset(); + check("the page offers the whole editor", odr.editing.scope() === "document"); + document.body.setAttribute("data-odr-editing-scope", "run"); + check("until the host narrows it", odr.editing.scope() === "run"); + + select(run(11).firstChild, 5); + check("typing inside a run is still taken", input("insertText", "X") === "taken"); + check("as one setText", ops().length === 1 && ops()[0].op === "setText"); + + // Each refusal lands in a run of its own: a repeat on one run within + // two seconds is dropped. + select(run(31).firstChild, 2); + check("Enter is refused", input("insertParagraph") === "refused"); + check("as out of scope", refusals.pop() === "outOfScope 1010"); + check("and the paragraph stands", texts() === "firstX run a link and a tail|bold|third|"); + + selectRuns(13, 5, 13, 5); + check("a delete inside a run is taken", input("deleteContentBackward") === "taken"); + select(run(13).firstChild, 0); + check("a backspace at a run's start is refused", input("deleteContentBackward") === "refused"); + check("as out of scope, not as a range", refusals.pop() === "outOfScope 1010"); + + select(run(21).firstChild, 0); + check("so is one at a paragraph's start", input("deleteContentBackward") === "refused"); + check("with the same code", refusals.pop() === "outOfScope 1010"); + + selectRuns(11, 5, 13, 4); + check("a selection over two runs is refused", input("insertText", "-") === "refused"); + check("as out of scope", refusals.pop() === "outOfScope 1010"); + check("and both runs stand", run(12) !== null); + + var picture = paragraph(40); + select(picture, 0, picture, picture.childNodes.length); + check( + "typing into a paragraph holding no run is refused", + input("insertText", "x") === "refused" + ); + check("because it would open one", refusals.pop() === "outOfScope 1010"); + + if (typeof DataTransfer === "function") { + var oneLine = new DataTransfer(); + oneLine.setData("text/plain", "flat"); + select(run(31).firstChild, 2); + check( + "a one-line paste is taken", + input("insertFromPaste", undefined, oneLine) === "taken" + ); + check("into the run", run(31).textContent === "thflatird"); + + var twoLines = new DataTransfer(); + twoLines.setData("text/plain", "two\nlines"); + select(run(12).firstChild, 2); + check( + "a paste holding a line break is refused", + input("insertFromPaste", undefined, twoLines) === "refused" + ); + check("as out of scope", refusals.pop() === "outOfScope 1010"); + } + + check("the log holds only what one run took", ops().every(function (op) { + return op.op === "setText"; + })); + + document.body.removeAttribute("data-odr-editing-scope"); + check("taking the attribute off widens it again", odr.editing.scope() === "document"); + select(run(11).firstChild, 2); + check("and Enter is taken", input("insertParagraph") === "taken"); + // ---------------------------------------------- what is not an edit // A script rewriting the page is not a reader typing, so the log stays diff --git a/test/src/error_code_test.cpp b/test/src/error_code_test.cpp index 7fa82f87f..1c9b07020 100644 --- a/test/src/error_code_test.cpp +++ b/test/src/error_code_test.cpp @@ -104,6 +104,7 @@ TEST(ErrorCode, edit_band_is_pinned) { EXPECT_EQ(static_cast(ErrorCode::edit_unsupported), 1007); EXPECT_EQ(static_cast(ErrorCode::edit_range), 1008); EXPECT_EQ(static_cast(ErrorCode::edit_unnameable), 1009); + EXPECT_EQ(static_cast(ErrorCode::edit_out_of_scope), 1010); } TEST(ErrorCode, every_code_is_named_once) { diff --git a/test/src/html_test.cpp b/test/src/html_test.cpp index d1b093557..ff09a93f1 100644 --- a/test/src/html_test.cpp +++ b/test/src/html_test.cpp @@ -890,6 +890,27 @@ TEST(html, a_document_states_on_its_body_whether_it_can_be_edited) { EXPECT_NE(page.find(R"(data-odr-editable="true")"), std::string::npos); } +// The scope is host policy, so the page states it beside the editing state. +TEST(html, an_editable_render_states_its_editing_scope) { + EXPECT_NE( + render_odt(editing_config()).find(R"(data-odr-editing-scope="document")"), + std::string::npos); + + HtmlConfig run = editing_config(); + run.editing_scope = HtmlEditingScope::run; + EXPECT_NE(render_odt(run).find(R"(data-odr-editing-scope="run")"), + std::string::npos); +} + +// The attribute, not the name: the script names it too. +TEST(html, a_read_only_render_states_no_editing_scope) { + HtmlConfig config; + config.editing_scope = HtmlEditingScope::run; + + EXPECT_EQ(render_odt(config).find(R"(data-odr-editing-scope=")"), + std::string::npos); +} + // A read-only render carries no state, no lock and no address. TEST(html, a_read_only_render_writes_no_editing_scaffolding) { const std::string page = render_sheet( diff --git a/wasm/README.md b/wasm/README.md index 0aa37083d..99ce55dc5 100644 --- a/wasm/README.md +++ b/wasm/README.md @@ -89,6 +89,9 @@ On a sheet, `onCellsStale` names the formula cells an edit left computing an old input - the page marks them, and nothing recomputes one yet. `keyboardNavigation` and `keyboardShortcuts` in the config decide whether the page takes the arrow keys and the undo chord, for a host that has its own. +`editingScope` narrows a document view to one run of text at a time +(`HtmlEditingScope.run`); the page refuses the rest with code 1010, +`outOfScope`. `example/index.html` wires the whole surface. `isEditable()` and `isSavable()` answer for this document, where diff --git a/wasm/js/index.d.ts b/wasm/js/index.d.ts index 62aa4f5d4..89d0577ab 100644 --- a/wasm/js/index.d.ts +++ b/wasm/js/index.d.ts @@ -11,6 +11,7 @@ export interface EnumTables { HtmlColorScheme: Record; HtmlTableGridlines: Record; HtmlViewportMode: Record; + HtmlEditingScope: Record; PdfTextMode: Record; EncryptionState: Record; LogLevel: Record; @@ -87,6 +88,8 @@ export interface Content { export interface HtmlConfig { embedImages?: boolean; editable?: boolean; + /** How far an edit in a document view may reach; the page refuses the rest. */ + editingScope?: number; keyboardNavigation?: boolean; keyboardShortcuts?: boolean; textDocumentMargin?: boolean; diff --git a/wasm/src/wasm_core.cpp b/wasm/src/wasm_core.cpp index 050014f47..772c901bd 100644 --- a/wasm/src/wasm_core.cpp +++ b/wasm/src/wasm_core.cpp @@ -131,6 +131,9 @@ emscripten::val enum_tables() { entry("actual_size", HtmlViewportMode::actual_size), entry("none", HtmlViewportMode::none), entry("fit_width_by_view", HtmlViewportMode::fit_width_by_view))); + result.set("HtmlEditingScope", + table(entry("run", HtmlEditingScope::run), + entry("document", HtmlEditingScope::document))); result.set("PdfTextMode", table(entry("dual_layer", PdfTextMode::dual_layer), entry("single_layer", PdfTextMode::single_layer))); diff --git a/wasm/src/wasm_html.cpp b/wasm/src/wasm_html.cpp index 6de09a3e4..41431c962 100644 --- a/wasm/src/wasm_html.cpp +++ b/wasm/src/wasm_html.cpp @@ -159,6 +159,7 @@ HtmlConfig to_html_config(const emscripten::val &value) { read(value, "embedImages", config.embed_images); read(value, "editable", config.editable); + read_enum(value, "editingScope", config.editing_scope); read(value, "keyboardNavigation", config.keyboard_navigation); read(value, "keyboardShortcuts", config.keyboard_shortcuts); read(value, "textDocumentMargin", config.text_document_margin); diff --git a/wasm/tests/enums.test.mjs b/wasm/tests/enums.test.mjs index f18f6e6ee..87f35e119 100644 --- a/wasm/tests/enums.test.mjs +++ b/wasm/tests/enums.test.mjs @@ -27,6 +27,7 @@ const pinned = { none: 3, fit_width_by_view: 4, }, + HtmlEditingScope: { run: 0, document: 1 }, PdfTextMode: { dual_layer: 0, single_layer: 1 }, EncryptionState: { unknown: 0, @@ -99,5 +100,6 @@ describe('enums', () => { assert.equal(enums.ErrorCode.unsupportedEdit, 1007); assert.equal(enums.ErrorCode.range, 1008); assert.equal(enums.ErrorCode.unnameableEdit, 1009); + assert.equal(enums.ErrorCode.outOfScope, 1010); }); }); From 9e2b45d1eed9e0316dc0043a7f8aa9eb44bd166b Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sun, 13 Sep 2026 09:14:02 +0200 Subject: [PATCH 2/3] docs(html): cut the prose around the editing scope Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QNQ2GtyAXXWGvnKBtwTEhQ --- CHANGELOG.md | 8 +++---- apple/include/OdrCoreObjC/ODRHtml.h | 4 ++-- docs/design/editing.md | 36 +++++++++++------------------ python/tests/test_html.py | 3 +-- src/odr/html.hpp | 5 ++-- test/browser/text/README.md | 5 ++-- test/browser/text/tests.html | 2 +- wasm/README.md | 5 ++-- 8 files changed, 27 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a770b1c35..58b7b61b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,11 +16,9 @@ The release run heads these entries with the version and opens a fresh ## Unreleased -- `HtmlConfig::editing_scope` narrows the document view's editor to one run of - text at a time (`HtmlEditingScope::run`); the default, `document`, keeps the - whole editor. The page states it as `data-odr-editing-scope` and refuses an - edit past it with the new `ErrorCode::edit_out_of_scope` (1010, `outOfScope`) - through `odr.onEditRefused`. Bound in jni, apple, wasm and python. +- `HtmlConfig::editing_scope` narrows the document view's editor to one run + of text (`run`); the page refuses the rest with the new + `ErrorCode::edit_out_of_scope` (1010, `outOfScope`). Bound in every binding. - Writing a cell of an `.ods` takes the cached result of every formula reading it away, keeping the formula itself, so the saved file states no number that diff --git a/apple/include/OdrCoreObjC/ODRHtml.h b/apple/include/OdrCoreObjC/ODRHtml.h index fd84bf16b..cb6afc127 100644 --- a/apple/include/OdrCoreObjC/ODRHtml.h +++ b/apple/include/OdrCoreObjC/ODRHtml.h @@ -92,8 +92,8 @@ NS_SWIFT_NAME(HtmlConfig) @property(nonatomic) BOOL relativeResourcePaths; @property(nonatomic) BOOL editable; -/// The scope the document view's editor offers; the page refuses the rest -/// with `ODRErrorCode` 1010, `outOfScope`. +/// How much of the document editor the page offers; it refuses the rest with +/// `ODRErrorCode` 1010, `outOfScope`. @property(nonatomic) ODRHtmlEditingScope editingScope; /// Whether the view's scripts take the keys that move the selection. @property(nonatomic) BOOL keyboardNavigation; diff --git a/docs/design/editing.md b/docs/design/editing.md index c5c31f50b..4b0ae7246 100644 --- a/docs/design/editing.md +++ b/docs/design/editing.md @@ -399,11 +399,9 @@ refused; verify both on a device. ### 14. The scope is host policy, and the page refuses past it -`Document::is_editable` is an engine fact: the capability table asserts it, -and the wasm package, the python module and the CLI all consume it. What a -host *offers* of that is the host's own call - an edition that sells the full -editor sells it on top of the same core. So the core carries no edition and no -policy. It carries one seam, `HtmlConfig::editing_scope`, and one signal back, +`Document::is_editable` is an engine fact; what a host offers of it is the +host's call. The core carries no policy, only one seam, +`HtmlConfig::editing_scope`, and one signal back, `ErrorCode::edit_out_of_scope` (1010, `outOfScope`). | Scope | What the document editor takes | @@ -411,27 +409,21 @@ policy. It carries one seam, `HtmlConfig::editing_scope`, and one signal back, | `document` (default) | everything in decision 13 | | `run` | an edit that starts and ends in one run: one `setText` | -In scope `run` the editor refuses Enter, a paste holding a line break, a -selection reaching into another run, and a delete at a run's edge - which -reaches into the run before it. A paragraph holding no run is refused too: the -text would open one, and that is an `insertText`. +Scope `run` refuses Enter, a paste holding a line break, a selection over two +runs, a delete at a run's edge, and a write into a paragraph holding no run, +which would open one. -**Why on the config and not on the document:** the same `.odt` renders under -both scopes; the document did not change, the host's offer did. And the -renderer already writes host policy onto `` (decision 12), so the scope -is one more attribute there, `data-odr-editing-scope`. +**Why on the config:** the document did not change, the host's offer did. The +scope joins the other host policy on `` (decision 12) as +`data-odr-editing-scope`. -**Why the editor reads it per edit, not once:** a host that widens the scope -mid-session - a purchase went through - sets the attribute and keeps the page. +**Why the editor reads it per edit:** a host widens it by setting the +attribute, with no second render. -**Why the refusal is its own code:** the host maps 1010 to an explanation of -what the wider scope offers, where every other code is a shrug. A delete at a -run's edge was refused with `range` before the full editor existed, so nothing -a reader could do went away with the scope; only the code changed. +**Why its own code:** a host maps 1010 to what the wider scope offers. -**Why replay does not check it:** the page cannot produce an operation past -its scope, and a check in `Document::edit` would refuse the *save*, after the -reader typed. A second guard can take a scope argument later. +**Why replay does not check it:** the page cannot produce an operation past its +scope, and a check in `Document::edit` would refuse the save. ## What landed, and what did not diff --git a/python/tests/test_html.py b/python/tests/test_html.py index c76c4f42f..93ff66825 100644 --- a/python/tests/test_html.py +++ b/python/tests/test_html.py @@ -113,8 +113,7 @@ def render(config): def test_editing_scope_reaches_the_html(odt_path): - # The C++ suite covers what the page refuses; this only proves the scope - # crosses the binding. + # proves the scope crosses the binding; the C++ suite covers the rest def render(config): file = pyodr.open(str(odt_path)) service = pyodr.html.translate(file, config) diff --git a/src/odr/html.hpp b/src/odr/html.hpp index 08536a0ed..988394299 100644 --- a/src/odr/html.hpp +++ b/src/odr/html.hpp @@ -142,9 +142,8 @@ struct HtmlConfig { /// edit operation names, and the editor script. The mode itself starts off - /// the host turns it on with `odr.editing.enable()`. bool editable{false}; - /// The scope the document view's editor offers; the page refuses the rest - /// with @ref ErrorCode::edit_out_of_scope. Sheet and plain-text views - /// ignore it. + /// How much of the document editor the page offers; it refuses the rest + /// with @ref ErrorCode::edit_out_of_scope. Only the document view reads it. HtmlEditingScope editing_scope{HtmlEditingScope::document}; /// Whether the view's scripts take the keys that move the selection: the diff --git a/test/browser/text/README.md b/test/browser/text/README.md index 00928f137..9114b8744 100644 --- a/test/browser/text/README.md +++ b/test/browser/text/README.md @@ -50,9 +50,8 @@ Why the checks look the way they do: `document_edit_test.cpp` replays the same shapes in C++, which is what keeps the two sides from drifting apart. -- **Scope `run` is checked on the same fixture.** `odr.editing.scope()` reads - `data-odr-editing-scope` off `` per edit, so the group sets the - attribute, checks what is refused with code 1010, and takes it off again. +- **Scope `run` runs on the same fixture**: the group sets + `data-odr-editing-scope` on ``, which the editor reads per edit. **Scripted editing is not the editing a reader does, which is why no check uses `execCommand`.** Chrome's scripted path raises no cancelable `beforeinput`, so diff --git a/test/browser/text/tests.html b/test/browser/text/tests.html index b578205e2..8088c24b4 100644 --- a/test/browser/text/tests.html +++ b/test/browser/text/tests.html @@ -517,7 +517,7 @@ check("as out of scope", refusals.pop() === "outOfScope 1010"); check("and the paragraph stands", texts() === "firstX run a link and a tail|bold|third|"); - selectRuns(13, 5, 13, 5); + select(run(13).firstChild, 5); check("a delete inside a run is taken", input("deleteContentBackward") === "taken"); select(run(13).firstChild, 0); check("a backspace at a run's start is refused", input("deleteContentBackward") === "refused"); diff --git a/wasm/README.md b/wasm/README.md index 99ce55dc5..de4ae8e90 100644 --- a/wasm/README.md +++ b/wasm/README.md @@ -89,9 +89,8 @@ On a sheet, `onCellsStale` names the formula cells an edit left computing an old input - the page marks them, and nothing recomputes one yet. `keyboardNavigation` and `keyboardShortcuts` in the config decide whether the page takes the arrow keys and the undo chord, for a host that has its own. -`editingScope` narrows a document view to one run of text at a time -(`HtmlEditingScope.run`); the page refuses the rest with code 1010, -`outOfScope`. +`editingScope` narrows a document view to one run of text at a time; the page +refuses the rest with code 1010, `outOfScope`. `example/index.html` wires the whole surface. `isEditable()` and `isSavable()` answer for this document, where From cc952dfa18d6eec22c247de4856268ea76b34cff Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sun, 13 Sep 2026 09:33:10 +0200 Subject: [PATCH 3/3] feat(html): the narrow editing scope is a paragraph, not a run A run is a split the reader cannot see: Word splits runs by revision session, so a wall at a run stands in the middle of uniform text. A paragraph is a unit the reader sees, so a refusal there has a visible reason. Scope `paragraph` takes any edit inside one paragraph and refuses Enter, a merge, a multi-line paste and a selection over two paragraphs. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QNQ2GtyAXXWGvnKBtwTEhQ --- CHANGELOG.md | 4 +- apple/include/OdrCoreObjC/ODRHtml.h | 6 +-- apple/src/ODRHtml.mm | 2 +- apple/tests/OdrCoreTests.swift | 4 +- docs/design/editing.md | 9 ++-- .../opendocument/core/HtmlEditingScope.java | 2 +- jni/tests/app/opendocument/core/HtmlTest.java | 12 ++--- python/src/bind_html.cpp | 2 +- python/tests/test_html.py | 4 +- src/odr/html.hpp | 4 +- src/odr/internal/html/common.cpp | 4 +- src/odr/internal/html/frontend/document.js | 12 ++--- src/odr/internal/html/frontend/editing.js | 8 +-- test/browser/text/README.md | 2 +- test/browser/text/tests.html | 49 ++++++++++--------- test/src/html_test.cpp | 8 +-- wasm/README.md | 2 +- wasm/src/wasm_core.cpp | 2 +- wasm/tests/enums.test.mjs | 2 +- 19 files changed, 70 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58b7b61b5..762dbbe6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,8 +16,8 @@ The release run heads these entries with the version and opens a fresh ## Unreleased -- `HtmlConfig::editing_scope` narrows the document view's editor to one run - of text (`run`); the page refuses the rest with the new +- `HtmlConfig::editing_scope` narrows the document view's editor to edits + inside one paragraph (`paragraph`); the page refuses the rest with the new `ErrorCode::edit_out_of_scope` (1010, `outOfScope`). Bound in every binding. - Writing a cell of an `.ods` takes the cached result of every formula reading diff --git a/apple/include/OdrCoreObjC/ODRHtml.h b/apple/include/OdrCoreObjC/ODRHtml.h index cb6afc127..a5253bd8e 100644 --- a/apple/include/OdrCoreObjC/ODRHtml.h +++ b/apple/include/OdrCoreObjC/ODRHtml.h @@ -54,9 +54,9 @@ typedef NS_ENUM(NSInteger, ODRHtmlViewportMode) { /// How far an edit in a document view may reach. Host policy, not an engine /// fact. typedef NS_ENUM(NSInteger, ODRHtmlEditingScope) { - /// Inside one run of text: `setText` only. - ODRHtmlEditingScopeRun = 0, - /// Across runs and paragraphs: every operation. + /// Inside one paragraph: no split and no merge. + ODRHtmlEditingScopeParagraph = 0, + /// Across paragraphs: every operation. ODRHtmlEditingScopeDocument, } NS_SWIFT_NAME(HtmlEditingScope); diff --git a/apple/src/ODRHtml.mm b/apple/src/ODRHtml.mm index b89053d90..e5bfae7b4 100644 --- a/apple/src/ODRHtml.mm +++ b/apple/src/ODRHtml.mm @@ -39,7 +39,7 @@ ODR_SAME_ENUM(ODRHtmlViewportModeFitWidthByView, odr::HtmlViewportMode::fit_width_by_view); -ODR_SAME_ENUM(ODRHtmlEditingScopeRun, odr::HtmlEditingScope::run); +ODR_SAME_ENUM(ODRHtmlEditingScopeParagraph, odr::HtmlEditingScope::paragraph); ODR_SAME_ENUM(ODRHtmlEditingScopeDocument, odr::HtmlEditingScope::document); ODR_SAME_ENUM(ODRPdfTextModeDualLayer, odr::PdfTextMode::dual_layer); diff --git a/apple/tests/OdrCoreTests.swift b/apple/tests/OdrCoreTests.swift index bdc49ed32..5ec13bf37 100644 --- a/apple/tests/OdrCoreTests.swift +++ b/apple/tests/OdrCoreTests.swift @@ -187,7 +187,7 @@ final class HtmlTests: XCTestCase { let config = HtmlConfig() config.editable = true - config.editingScope = .run + config.editingScope = .paragraph let file = try DecodedFile.decode(path: try Fixture.odt()) let service = try HtmlTranslator.translate(file: file, config: config) @@ -195,7 +195,7 @@ final class HtmlTests: XCTestCase { let html = try XCTUnwrap(service.views.first).writeHtml(resources: &resources) XCTAssertTrue( - html.contains("data-odr-editing-scope=\"run\""), "the scope did not reach the html") + html.contains("data-odr-editing-scope=\"paragraph\""), "the scope did not reach the html") } /// The C++ suite covers where the floor lands; this only proves the margin diff --git a/docs/design/editing.md b/docs/design/editing.md index 4b0ae7246..391cadd29 100644 --- a/docs/design/editing.md +++ b/docs/design/editing.md @@ -407,11 +407,12 @@ host's call. The core carries no policy, only one seam, | Scope | What the document editor takes | |---|---| | `document` (default) | everything in decision 13 | -| `run` | an edit that starts and ends in one run: one `setText` | +| `paragraph` | an edit that starts and ends in one paragraph | -Scope `run` refuses Enter, a paste holding a line break, a selection over two -runs, a delete at a run's edge, and a write into a paragraph holding no run, -which would open one. +Scope `paragraph` refuses Enter, Backspace at a paragraph start, a paste +holding a line break, and a selection over two paragraphs. A paragraph is a +unit the reader sees, where a run is not: Word splits runs by revision session, +so a wall at a run would stand in the middle of uniform text. **Why on the config:** the document did not change, the host's offer did. The scope joins the other host policy on `` (decision 12) as diff --git a/jni/java/app/opendocument/core/HtmlEditingScope.java b/jni/java/app/opendocument/core/HtmlEditingScope.java index c352bfb4d..3b76be9fb 100644 --- a/jni/java/app/opendocument/core/HtmlEditingScope.java +++ b/jni/java/app/opendocument/core/HtmlEditingScope.java @@ -2,7 +2,7 @@ /** Mirrors {@code odr::HtmlEditingScope}; constant order must match the C++ declaration. */ public enum HtmlEditingScope { - RUN, DOCUMENT; + PARAGRAPH, DOCUMENT; static HtmlEditingScope fromNative(int code) { return code < 0 ? null : values()[code]; diff --git a/jni/tests/app/opendocument/core/HtmlTest.java b/jni/tests/app/opendocument/core/HtmlTest.java index 1d7479443..68049aaa6 100644 --- a/jni/tests/app/opendocument/core/HtmlTest.java +++ b/jni/tests/app/opendocument/core/HtmlTest.java @@ -71,14 +71,14 @@ void editingScopeReachesTheHtml() throws IOException { editable.editable = true; assertTrue(renderOdt(editable).contains("data-odr-editing-scope=\"document\"")); - HtmlConfig run = new HtmlConfig(); - run.editable = true; - run.editingScope = HtmlEditingScope.RUN; - assertTrue(renderOdt(run).contains("data-odr-editing-scope=\"run\"")); + HtmlConfig narrowed = new HtmlConfig(); + narrowed.editable = true; + narrowed.editingScope = HtmlEditingScope.PARAGRAPH; + assertTrue(renderOdt(narrowed).contains("data-odr-editing-scope=\"paragraph\"")); DecodedFile file = Odr.open(TestFiles.odtFile(tempDir).toString()); - HtmlConfig readBack = Html.translate(file, run).config(); - assertEquals(HtmlEditingScope.RUN, readBack.editingScope); + HtmlConfig readBack = Html.translate(file, narrowed).config(); + assertEquals(HtmlEditingScope.PARAGRAPH, readBack.editingScope); } /** The C++ suite covers where the floor lands; this only proves it crosses JNI. */ diff --git a/python/src/bind_html.cpp b/python/src/bind_html.cpp index 8291ac8ca..d833e4ca9 100644 --- a/python/src/bind_html.cpp +++ b/python/src/bind_html.cpp @@ -43,7 +43,7 @@ void odr_python::bind_html(py::module_ &m) { .value("fit_width_by_view", odr::HtmlViewportMode::fit_width_by_view); py::enum_(m, "HtmlEditingScope") - .value("run", odr::HtmlEditingScope::run) + .value("paragraph", odr::HtmlEditingScope::paragraph) .value("document", odr::HtmlEditingScope::document); py::enum_(m, "PdfTextMode") diff --git a/python/tests/test_html.py b/python/tests/test_html.py index 93ff66825..6300530bf 100644 --- a/python/tests/test_html.py +++ b/python/tests/test_html.py @@ -125,8 +125,8 @@ def render(config): config = pyodr.HtmlConfig() config.editable = True - config.editing_scope = pyodr.HtmlEditingScope.run - assert 'data-odr-editing-scope="run"' in render(config) + config.editing_scope = pyodr.HtmlEditingScope.paragraph + assert 'data-odr-editing-scope="paragraph"' in render(config) def test_min_content_margin_reaches_the_html(odt_path): diff --git a/src/odr/html.hpp b/src/odr/html.hpp index 988394299..c1c4ffef9 100644 --- a/src/odr/html.hpp +++ b/src/odr/html.hpp @@ -102,8 +102,8 @@ enum class HtmlViewportMode { /// How far an edit in a document view may reach. Host policy, not an engine /// fact. enum class HtmlEditingScope { - run, ///< inside one run of text: `setText` only - document, ///< across runs and paragraphs: every operation + paragraph, ///< inside one paragraph: no split and no merge + document, ///< across paragraphs: every operation }; /// How text is emitted in PDF→HTML output. Neither mode needs JavaScript. diff --git a/src/odr/internal/html/common.cpp b/src/odr/internal/html/common.cpp index 9ab3a189c..26192001d 100644 --- a/src/odr/internal/html/common.cpp +++ b/src/odr/internal/html/common.cpp @@ -361,8 +361,8 @@ std::string html::keyboard_classes(const HtmlConfig &config) { std::string_view html::editing_scope_name(const HtmlEditingScope scope) { switch (scope) { - case HtmlEditingScope::run: - return "run"; + case HtmlEditingScope::paragraph: + return "paragraph"; case HtmlEditingScope::document: default: return "document"; diff --git a/src/odr/internal/html/frontend/document.js b/src/odr/internal/html/frontend/document.js index 355347998..567508703 100644 --- a/src/odr/internal/html/frontend/document.js +++ b/src/odr/internal/html/frontend/document.js @@ -796,11 +796,11 @@ }; } - /// In scope `run` an edit is one `setText`: it starts and ends in one run. + /// In scope `paragraph` an edit starts and ends in one paragraph. function outOfScope(at) { return ( - odr.editing.scope() === "run" && - (at.start.run === null || at.start.run !== at.end.run) + odr.editing.scope() === "paragraph" && + at.start.paragraph !== at.end.paragraph ); } @@ -877,7 +877,7 @@ } if (type === "insertParagraph") { - if (odr.editing.scope() === "run") { + if (odr.editing.scope() === "paragraph") { refuse(event, "outOfScope", at); return; } @@ -902,7 +902,7 @@ // several lines open paragraphs if ( outOfScope(at) || - (odr.editing.scope() === "run" && /[\r\n]/.test(pasted)) + (odr.editing.scope() === "paragraph" && /[\r\n]/.test(pasted)) ) { refuse(event, "outOfScope", at); return; @@ -929,7 +929,7 @@ event.preventDefault(); return; } - // a delete at a run's edge reaches into the run before it + // a delete at a paragraph's start reaches into the one before it if (outOfScope(covering)) { refuse(event, "outOfScope", at); return; diff --git a/src/odr/internal/html/frontend/editing.js b/src/odr/internal/html/frontend/editing.js index ab61ae10a..eb26d3962 100644 --- a/src/odr/internal/html/frontend/editing.js +++ b/src/odr/internal/html/frontend/editing.js @@ -137,11 +137,11 @@ isEditable: function () { return editable; }, - /// `run` or `document`, as `` states it. Read per edit, so a host - /// can widen it without a render. + /// `paragraph` or `document`, as `` states it. Read per edit, so a + /// host can widen it without a render. scope: function () { - return body.getAttribute("data-odr-editing-scope") === "run" - ? "run" + return body.getAttribute("data-odr-editing-scope") === "paragraph" + ? "paragraph" : "document"; }, diff --git a/test/browser/text/README.md b/test/browser/text/README.md index 9114b8744..4a9413aee 100644 --- a/test/browser/text/README.md +++ b/test/browser/text/README.md @@ -50,7 +50,7 @@ Why the checks look the way they do: `document_edit_test.cpp` replays the same shapes in C++, which is what keeps the two sides from drifting apart. -- **Scope `run` runs on the same fixture**: the group sets +- **Scope `paragraph` runs on the same fixture**: the group sets `data-odr-editing-scope` on ``, which the editor reads per edit. **Scripted editing is not the editing a reader does, which is why no check uses diff --git a/test/browser/text/tests.html b/test/browser/text/tests.html index 8088c24b4..2be972138 100644 --- a/test/browser/text/tests.html +++ b/test/browser/text/tests.html @@ -499,46 +499,44 @@ tail.querySelector("x-s[style]") !== null ); - // ---------------------------------------------------- scope run + // ---------------------------------------------- scope paragraph reset(); check("the page offers the whole editor", odr.editing.scope() === "document"); - document.body.setAttribute("data-odr-editing-scope", "run"); - check("until the host narrows it", odr.editing.scope() === "run"); + document.body.setAttribute("data-odr-editing-scope", "paragraph"); + check("until the host narrows it", odr.editing.scope() === "paragraph"); select(run(11).firstChild, 5); - check("typing inside a run is still taken", input("insertText", "X") === "taken"); - check("as one setText", ops().length === 1 && ops()[0].op === "setText"); + check("typing inside a run is taken", input("insertText", "X") === "taken"); + selectRuns(11, 6, 13, 4); + check("and so is an edit across two runs", input("insertText", "-") === "taken"); + check("in one paragraph", texts() === "firstX- a tail|bold|third|"); + select(run(13).firstChild, 0); + check("and a backspace over a run boundary", input("deleteContentBackward") === "taken"); // Each refusal lands in a run of its own: a repeat on one run within // two seconds is dropped. select(run(31).firstChild, 2); check("Enter is refused", input("insertParagraph") === "refused"); check("as out of scope", refusals.pop() === "outOfScope 1010"); - check("and the paragraph stands", texts() === "firstX run a link and a tail|bold|third|"); - - select(run(13).firstChild, 5); - check("a delete inside a run is taken", input("deleteContentBackward") === "taken"); - select(run(13).firstChild, 0); - check("a backspace at a run's start is refused", input("deleteContentBackward") === "refused"); - check("as out of scope, not as a range", refusals.pop() === "outOfScope 1010"); + check("and the paragraph stands", texts() === "firstX a tail|bold|third|"); select(run(21).firstChild, 0); - check("so is one at a paragraph's start", input("deleteContentBackward") === "refused"); - check("with the same code", refusals.pop() === "outOfScope 1010"); + check("a backspace at a paragraph's start is refused", input("deleteContentBackward") === "refused"); + check("as out of scope, not as a range", refusals.pop() === "outOfScope 1010"); - selectRuns(11, 5, 13, 4); - check("a selection over two runs is refused", input("insertText", "-") === "refused"); + selectRuns(11, 2, 31, 2); + check("a selection over two paragraphs is refused", input("insertText", "=") === "refused"); check("as out of scope", refusals.pop() === "outOfScope 1010"); - check("and both runs stand", run(12) !== null); + check("and both stand", texts() === "firstX a tail|bold|third|"); var picture = paragraph(40); select(picture, 0, picture, picture.childNodes.length); check( - "typing into a paragraph holding no run is refused", - input("insertText", "x") === "refused" + "typing into a paragraph holding no run is taken", + input("insertText", "x") === "taken" ); - check("because it would open one", refusals.pop() === "outOfScope 1010"); + check("and opens one", picture.textContent === "x"); if (typeof DataTransfer === "function") { var oneLine = new DataTransfer(); @@ -552,7 +550,7 @@ var twoLines = new DataTransfer(); twoLines.setData("text/plain", "two\nlines"); - select(run(12).firstChild, 2); + select(run(13).firstChild, 2); check( "a paste holding a line break is refused", input("insertFromPaste", undefined, twoLines) === "refused" @@ -560,9 +558,12 @@ check("as out of scope", refusals.pop() === "outOfScope 1010"); } - check("the log holds only what one run took", ops().every(function (op) { - return op.op === "setText"; - })); + check( + "the log names no paragraph operation", + ops().every(function (op) { + return op.op === "setText" || op.op === "removeElement" || op.op === "insertText"; + }) + ); document.body.removeAttribute("data-odr-editing-scope"); check("taking the attribute off widens it again", odr.editing.scope() === "document"); diff --git a/test/src/html_test.cpp b/test/src/html_test.cpp index ff09a93f1..f57e5e9c9 100644 --- a/test/src/html_test.cpp +++ b/test/src/html_test.cpp @@ -896,16 +896,16 @@ TEST(html, an_editable_render_states_its_editing_scope) { render_odt(editing_config()).find(R"(data-odr-editing-scope="document")"), std::string::npos); - HtmlConfig run = editing_config(); - run.editing_scope = HtmlEditingScope::run; - EXPECT_NE(render_odt(run).find(R"(data-odr-editing-scope="run")"), + HtmlConfig narrowed = editing_config(); + narrowed.editing_scope = HtmlEditingScope::paragraph; + EXPECT_NE(render_odt(narrowed).find(R"(data-odr-editing-scope="paragraph")"), std::string::npos); } // The attribute, not the name: the script names it too. TEST(html, a_read_only_render_states_no_editing_scope) { HtmlConfig config; - config.editing_scope = HtmlEditingScope::run; + config.editing_scope = HtmlEditingScope::paragraph; EXPECT_EQ(render_odt(config).find(R"(data-odr-editing-scope=")"), std::string::npos); diff --git a/wasm/README.md b/wasm/README.md index de4ae8e90..8723a8c14 100644 --- a/wasm/README.md +++ b/wasm/README.md @@ -89,7 +89,7 @@ On a sheet, `onCellsStale` names the formula cells an edit left computing an old input - the page marks them, and nothing recomputes one yet. `keyboardNavigation` and `keyboardShortcuts` in the config decide whether the page takes the arrow keys and the undo chord, for a host that has its own. -`editingScope` narrows a document view to one run of text at a time; the page +`editingScope` narrows a document view to edits inside one paragraph; the page refuses the rest with code 1010, `outOfScope`. `example/index.html` wires the whole surface. diff --git a/wasm/src/wasm_core.cpp b/wasm/src/wasm_core.cpp index 772c901bd..6fa973c0b 100644 --- a/wasm/src/wasm_core.cpp +++ b/wasm/src/wasm_core.cpp @@ -132,7 +132,7 @@ emscripten::val enum_tables() { entry("none", HtmlViewportMode::none), entry("fit_width_by_view", HtmlViewportMode::fit_width_by_view))); result.set("HtmlEditingScope", - table(entry("run", HtmlEditingScope::run), + table(entry("paragraph", HtmlEditingScope::paragraph), entry("document", HtmlEditingScope::document))); result.set("PdfTextMode", table(entry("dual_layer", PdfTextMode::dual_layer), diff --git a/wasm/tests/enums.test.mjs b/wasm/tests/enums.test.mjs index 87f35e119..a5b88cb80 100644 --- a/wasm/tests/enums.test.mjs +++ b/wasm/tests/enums.test.mjs @@ -27,7 +27,7 @@ const pinned = { none: 3, fit_width_by_view: 4, }, - HtmlEditingScope: { run: 0, document: 1 }, + HtmlEditingScope: { paragraph: 0, document: 1 }, PdfTextMode: { dual_layer: 0, single_layer: 1 }, EncryptionState: { unknown: 0,