From c1358344b6457f088073f1cb68920b291349082b Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sun, 13 Sep 2026 18:02:00 +0200 Subject: [PATCH] feat(bindings): Text::set_style in python, java, objective-c and the npm package Python binds `Text.set_style(TextStyle)` as it stands. Java and Objective-C get the same, with `TextStyle` constructible and its fields writable, so a caller builds the delta it passes; a `fontName` is refused before it reaches C++, since the C++ one borrows from a document. The npm package's `setTextStyle(id, style)` takes the page's style object and replays it through the envelope, so the wire's parser validates it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0137vd79NGaB8nfLsdPoghM4 --- CHANGELOG.md | 4 + .../include/OdrCoreObjC/ODRDocumentElement.h | 4 + apple/include/OdrCoreObjC/ODRStyle.h | 30 +++-- apple/src/ODRDocumentElement.mm | 7 ++ apple/src/ODRPrivate.h | 3 + apple/src/ODRStyle.mm | 43 +++++++ apple/tests/OdrCoreTests.swift | 38 ++++++ docs/design/document-editing.md | 12 +- jni/java/app/opendocument/core/Text.java | 11 ++ jni/java/app/opendocument/core/TextStyle.java | 31 +++-- jni/src/jni_convert.hpp | 3 + jni/src/jni_document.cpp | 9 ++ jni/src/jni_style.cpp | 111 ++++++++++++++++++ .../app/opendocument/core/DocumentTest.java | 36 ++++++ python/src/bind_document.cpp | 1 + python/tests/test_document.py | 31 +++++ wasm/js/index.d.ts | 21 ++++ wasm/js/index.js | 7 ++ wasm/src/wasm_document.cpp | 20 ++++ wasm/tests/edit.test.mjs | 31 +++++ 20 files changed, 424 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a736fff3..6cbe68fa1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,10 @@ The release run heads these entries with the version and opens a fresh chords, and `odr.onSelectionChange` for a host's buttons. A toggle on a collapsed caret marks the next typed text. Scope `paragraph` refuses it. +- `Text::set_style` in every binding: `Text.set_style` in python, + `Text.setStyle` in Java, `-[ODRText setStyle:error:]` in Objective-C, both + with a constructible `TextStyle`, and `setTextStyle(id, style)` in npm. + - A run that is both underlined and struck through renders both lines. The page wrote two `text-decoration` declarations, and the second replaced the first. diff --git a/apple/include/OdrCoreObjC/ODRDocumentElement.h b/apple/include/OdrCoreObjC/ODRDocumentElement.h index 08c010cd4..3b9114a66 100644 --- a/apple/include/OdrCoreObjC/ODRDocumentElement.h +++ b/apple/include/OdrCoreObjC/ODRDocumentElement.h @@ -195,6 +195,10 @@ NS_SWIFT_NAME(Text) /// Replaces the text. Only meaningful on an editable document. - (BOOL)setContent:(NSString *)content error:(NSError **)error; @property(nonatomic, readonly) ODRTextStyle *style; +/// States the non-nil properties of `style` on the run and leaves the rest; +/// a `backgroundColor` with alpha 0 removes a highlight. `fontName`, +/// `fontShadow` and `fontPosition` refuse with `ODRErrorUnsupportedOperation`. +- (BOOL)setStyle:(ODRTextStyle *)style error:(NSError **)error; @end /// `odr::Link`. diff --git a/apple/include/OdrCoreObjC/ODRStyle.h b/apple/include/OdrCoreObjC/ODRStyle.h index 3835f1ace..874a10862 100644 --- a/apple/include/OdrCoreObjC/ODRStyle.h +++ b/apple/include/OdrCoreObjC/ODRStyle.h @@ -141,28 +141,32 @@ NS_SWIFT_NAME(DirectionalString) /// Every property is optional, and `nil` means the document did not specify it /// rather than that it is off. The enum-valued ones are boxed in `NSNumber` /// for that reason; unbox with `ODRFontWeight(rawValue:)`. +/// +/// A caller builds one for `-[ODRText setStyle:error:]`: `init` leaves every +/// property `nil`, and a `nil` property is left alone on the run. NS_SWIFT_NAME(TextStyle) @interface ODRTextStyle : NSObject -@property(nonatomic, readonly, nullable, copy) NSString *fontName; -@property(nonatomic, readonly, nullable) ODRMeasure *fontSize; +/// Read only: it borrows from the document, and `setStyle:` refuses it. +@property(nonatomic, nullable, copy) NSString *fontName; +@property(nonatomic, nullable) ODRMeasure *fontSize; /// `ODRFontWeight`, boxed. -@property(nonatomic, readonly, nullable) NSNumber *fontWeight; +@property(nonatomic, nullable) NSNumber *fontWeight; /// `ODRFontStyle`, boxed. -@property(nonatomic, readonly, nullable) NSNumber *fontStyle; +@property(nonatomic, nullable) NSNumber *fontStyle; /// `BOOL`, boxed. -@property(nonatomic, readonly, nullable) NSNumber *fontUnderline; +@property(nonatomic, nullable) NSNumber *fontUnderline; /// `BOOL`, boxed. -@property(nonatomic, readonly, nullable) NSNumber *fontLineThrough; -@property(nonatomic, readonly, nullable, copy) NSString *fontShadow; -/// `ODRColor`, boxed in an `NSValue`. -@property(nonatomic, readonly, nullable) NSValue *fontColor; +@property(nonatomic, nullable) NSNumber *fontLineThrough; +@property(nonatomic, nullable, copy) NSString *fontShadow; /// `ODRColor`, boxed in an `NSValue`. -@property(nonatomic, readonly, nullable) NSValue *backgroundColor; +@property(nonatomic, nullable) NSValue *fontColor; +/// `ODRColor`, boxed in an `NSValue`; an alpha of 0 takes a highlight away. +@property(nonatomic, nullable) NSValue *backgroundColor; /// `ODRFontPosition`, boxed. -@property(nonatomic, readonly, nullable) NSNumber *fontPosition; +@property(nonatomic, nullable) NSNumber *fontPosition; -- (instancetype)init NS_UNAVAILABLE; -+ (instancetype)new NS_UNAVAILABLE; +/// Every property `nil`. +- (instancetype)init; @end /// Paragraph style — `odr::ParagraphStyle`. diff --git a/apple/src/ODRDocumentElement.mm b/apple/src/ODRDocumentElement.mm index 4af49bbe5..111cddd36 100644 --- a/apple/src/ODRDocumentElement.mm +++ b/apple/src/ODRDocumentElement.mm @@ -522,6 +522,13 @@ - (BOOL)setContent:(NSString *)content error:(NSError **)error { }); } +- (BOOL)setStyle:(ODRTextStyle *)style error:(NSError **)error { + return guarded(error, [&] { + self.handle.as_text().set_style([style handle]); + return YES; + }); +} + - (ODRTextStyle *)style { return guarded_value( [&]() -> ODRTextStyle * { diff --git a/apple/src/ODRPrivate.h b/apple/src/ODRPrivate.h index 0fda18713..112b0c1ff 100644 --- a/apple/src/ODRPrivate.h +++ b/apple/src/ODRPrivate.h @@ -103,6 +103,9 @@ NS_ASSUME_NONNULL_BEGIN @interface ODRTextStyle (Private) + (instancetype)styleWithHandle:(const odr::TextStyle &)handle; +/// The set properties as a `TextStyle`; throws `UnsupportedOperation` for a +/// `fontName`, which the C++ one borrows from a document. +- (odr::TextStyle)handle; @end @interface ODRParagraphStyle (Private) diff --git a/apple/src/ODRStyle.mm b/apple/src/ODRStyle.mm index db958d6af..8a519611c 100644 --- a/apple/src/ODRStyle.mm +++ b/apple/src/ODRStyle.mm @@ -3,6 +3,7 @@ #import "ODRInternal.h" #import "ODRPrivate.h" +#include #include #include @@ -89,6 +90,12 @@ return value.has_value() ? to_nsstring(*value) : nil; } +odr::Color unbox_color(NSValue *const value) { + ODRColor color{}; + [value getValue:&color size:sizeof(color)]; + return {color.red, color.green, color.blue, color.alpha}; +} + } // namespace #pragma mark - ODRMeasure @@ -214,6 +221,42 @@ + (instancetype)styleWithHandle:(const odr::TextStyle &)handle { return result; } +- (odr::TextStyle)handle { + odr::TextStyle result; + if (_fontName != nil) { + throw odr::UnsupportedOperation(); + } + if (_fontSize != nil) { + result.font_size = _fontSize.handle; + } + if (_fontWeight != nil) { + result.font_weight = static_cast(_fontWeight.integerValue); + } + if (_fontStyle != nil) { + result.font_style = static_cast(_fontStyle.integerValue); + } + if (_fontUnderline != nil) { + result.font_underline = _fontUnderline.boolValue != NO; + } + if (_fontLineThrough != nil) { + result.font_line_through = _fontLineThrough.boolValue != NO; + } + if (_fontShadow != nil) { + result.font_shadow = to_string(_fontShadow); + } + if (_fontColor != nil) { + result.font_color = unbox_color(_fontColor); + } + if (_backgroundColor != nil) { + result.background_color = unbox_color(_backgroundColor); + } + if (_fontPosition != nil) { + result.font_position = + static_cast(_fontPosition.integerValue); + } + return result; +} + @end @implementation ODRParagraphStyle diff --git a/apple/tests/OdrCoreTests.swift b/apple/tests/OdrCoreTests.swift index 5ec13bf37..c9243b9d9 100644 --- a/apple/tests/OdrCoreTests.swift +++ b/apple/tests/OdrCoreTests.swift @@ -382,6 +382,44 @@ final class DocumentSaveTests: XCTestCase { .asDocumentFile().document() } + func testSetStyleMarksARun() throws { + let document = try self.document() + let root = try XCTUnwrap(try document.rootElement()) + let text = try XCTUnwrap(root.firstDescendant(ofType: Text.self)) + + let style = TextStyle() + style.fontWeight = NSNumber(value: FontWeight.bold.rawValue) + style.fontUnderline = true + style.fontSize = Measure(string: "14pt") + try text.setStyle(style) + + let saved = try XCTUnwrap(try document.saveToMemory()) + let path = URL(fileURLWithPath: try temporaryDirectory()) + .appendingPathComponent("styled.odt") + try saved.write(to: path) + + let reloaded = try DecodedFile.decode(path: path.path) + .asDocumentFile().document() + let reloadedRoot = try XCTUnwrap(try reloaded.rootElement()) + let styled = try XCTUnwrap(reloadedRoot.firstDescendant(ofType: Text.self)).style + XCTAssertEqual(styled.fontWeight?.intValue, FontWeight.bold.rawValue) + XCTAssertEqual(styled.fontUnderline?.boolValue, true) + XCTAssertEqual(styled.fontSize?.stringValue, "14pt") + XCTAssertNil(styled.fontStyle) + } + + func testSetStyleRefusesAFontName() throws { + let document = try self.document() + let root = try XCTUnwrap(try document.rootElement()) + let text = try XCTUnwrap(root.firstDescendant(ofType: Text.self)) + + let style = TextStyle() + style.fontName = "Comic Sans" + XCTAssertThrowsError(try text.setStyle(style)) { error in + XCTAssertEqual((error as NSError).code, ODRError.unsupportedOperation.rawValue) + } + } + func testSaveToMemoryCarriesAnEdit() throws { let document = try self.document() XCTAssertTrue(document.isSavable) diff --git a/docs/design/document-editing.md b/docs/design/document-editing.md index fcdedbf23..d20c5e837 100644 --- a/docs/design/document-editing.md +++ b/docs/design/document-editing.md @@ -362,11 +362,10 @@ that names the frame rather than a range across text. ## Inline formatting -Status: **landed**, but for the bindings; the order of work below says what -is in. It covers what a reader changes on a stretch of text without -changing the text: bold, italic, underline, strikethrough, highlight, colour -and size. Font name, superscript and subscript are not in it; nothing asked -for them, and each is the same shape once these seven are in. +Status: **landed.** It covers what a reader changes on a stretch of text +without changing the text: bold, italic, underline, strikethrough, highlight, +colour and size. Font name, superscript and subscript are not in it; nothing +asked for them, and each is the same shape once these seven are in. ### What the reader changes, and where each format keeps it @@ -580,6 +579,9 @@ Each step is a pull request that builds and tests on its own. 4. **The browser.** `format()`, `onSelectionChange`, the four input types, the word rule for a collapsed caret, and a check page in `test/browser/text` asserting the log of each gesture. **Landed.** +5. **The bindings.** `Text::set_style` in python, Java, Objective-C and the + npm package, the last taking the page's style object and replaying it + through the envelope. **Landed.** ### Open questions diff --git a/jni/java/app/opendocument/core/Text.java b/jni/java/app/opendocument/core/Text.java index 0c5fb55c4..2c56cb0bc 100644 --- a/jni/java/app/opendocument/core/Text.java +++ b/jni/java/app/opendocument/core/Text.java @@ -14,6 +14,15 @@ public void setContent(String text) { setContentNative(handle(), text); } + /** + * States the non-null fields of {@code style} on the run and leaves the rest; a {@code + * backgroundColor} with alpha 0 removes a highlight. {@code fontName}, {@code fontShadow} and + * {@code fontPosition} are refused. + */ + public void setStyle(TextStyle style) { + setStyleNative(handle(), style); + } + public TextStyle style() { return styleNative(handle()); } @@ -22,5 +31,7 @@ public TextStyle style() { private native void setContentNative(long handle, String text); + private native void setStyleNative(long handle, TextStyle style); + private native TextStyle styleNative(long handle); } diff --git a/jni/java/app/opendocument/core/TextStyle.java b/jni/java/app/opendocument/core/TextStyle.java index 9e65182dd..a10ff4230 100644 --- a/jni/java/app/opendocument/core/TextStyle.java +++ b/jni/java/app/opendocument/core/TextStyle.java @@ -1,17 +1,26 @@ package app.opendocument.core; -/** Style of a text run. Mirrors {@code odr::TextStyle}; fields may be {@code null}. */ +/** + * Style of a text run. Mirrors {@code odr::TextStyle}; a {@code null} field is one the document + * does not state. A caller builds one for {@link Text#setStyle}: every field left {@code null} is + * left alone on the run. + */ public final class TextStyle { - public final String fontName; - public final Measure fontSize; - public final FontWeight fontWeight; - public final FontStyle fontStyle; - public final Boolean fontUnderline; - public final Boolean fontLineThrough; - public final String fontShadow; - public final Color fontColor; - public final Color backgroundColor; - public final FontPosition fontPosition; + /** Read only: it borrows from the document, and {@link Text#setStyle} refuses it. */ + public String fontName; + public Measure fontSize; + public FontWeight fontWeight; + public FontStyle fontStyle; + public Boolean fontUnderline; + public Boolean fontLineThrough; + public String fontShadow; + public Color fontColor; + /** An alpha of 0 takes a highlight away. */ + public Color backgroundColor; + public FontPosition fontPosition; + + /** Every field {@code null}. */ + public TextStyle() {} TextStyle( String fontName, diff --git a/jni/src/jni_convert.hpp b/jni/src/jni_convert.hpp index 0d0b99588..aaa620dba 100644 --- a/jni/src/jni_convert.hpp +++ b/jni/src/jni_convert.hpp @@ -55,6 +55,9 @@ jobject make_file_type_capabilities(JNIEnv *env, jobject html_config_to_java(JNIEnv *env, const odr::HtmlConfig &config); odr::HtmlConfig html_config_from_java(JNIEnv *env, jobject config); +/// The fields a Java `TextStyle` states; a `fontName` is refused, since the +/// C++ one borrows from a document. +odr::TextStyle text_style_from_java(JNIEnv *env, jobject style); /// Optional enum to a Java-side code; -1 encodes absent. template jint enum_code(const std::optional &value) { diff --git a/jni/src/jni_document.cpp b/jni/src/jni_document.cpp index f54cff102..30bf42845 100644 --- a/jni/src/jni_document.cpp +++ b/jni/src/jni_document.cpp @@ -702,6 +702,15 @@ Java_app_opendocument_core_Text_setContentNative(JNIEnv *env, jobject, [&] { element(handle).as_text().set_content(to_string(env, text)); }); } +extern "C" JNIEXPORT void JNICALL +Java_app_opendocument_core_Text_setStyleNative(JNIEnv *env, jobject, + jlong handle, jobject style) { + guarded(env, [&] { + element(handle).as_text().set_style( + odr_jni::text_style_from_java(env, style)); + }); +} + extern "C" JNIEXPORT jobject JNICALL Java_app_opendocument_core_Text_styleNative(JNIEnv *env, jobject, jlong handle) { diff --git a/jni/src/jni_style.cpp b/jni/src/jni_style.cpp index aa5e9f6bb..2bc29f63f 100644 --- a/jni/src/jni_style.cpp +++ b/jni/src/jni_style.cpp @@ -1,6 +1,8 @@ #include "jni_convert.hpp" #include "odr_jni.hpp" +#include + #include #include @@ -233,6 +235,115 @@ std::optional measure_from_java(JNIEnv *env, jobject value) { return result; } +namespace { + +/// A Java enum constant to the C++ enumerator it mirrors, by ordinal. +template +std::optional enum_from_java(JNIEnv *env, const jobject value) { + if (value == nullptr) { + return std::nullopt; + } + jclass cls = env->GetObjectClass(value); + const jint code = + env->CallIntMethod(value, env->GetMethodID(cls, "ordinal", "()I")); + env->DeleteLocalRef(cls); + return static_cast(code); +} + +std::optional boolean_from_java(JNIEnv *env, const jobject value) { + if (value == nullptr) { + return std::nullopt; + } + jclass cls = env->GetObjectClass(value); + const jboolean result = env->CallBooleanMethod( + value, env->GetMethodID(cls, "booleanValue", "()Z")); + env->DeleteLocalRef(cls); + return result != JNI_FALSE; +} + +std::optional color_from_java(JNIEnv *env, const jobject value) { + if (value == nullptr) { + return std::nullopt; + } + jclass cls = env->GetObjectClass(value); + const auto channel = [&](const char *name) { + return static_cast( + env->GetIntField(value, env->GetFieldID(cls, name, "I"))); + }; + const odr::Color result(channel("red"), channel("green"), channel("blue"), + channel("alpha")); + env->DeleteLocalRef(cls); + return result; +} + +} // namespace + +odr::TextStyle text_style_from_java(JNIEnv *env, const jobject style) { + odr::TextStyle result; + if (style == nullptr) { + return result; + } + jclass cls = env->GetObjectClass(style); + const auto field = [&](const char *name, const char *signature) { + return env->GetObjectField(style, env->GetFieldID(cls, name, signature)); + }; + const auto take = [&](jobject value, auto convert) { + auto converted = convert(value); + if (value != nullptr) { + env->DeleteLocalRef(value); + } + return converted; + }; + + if (const jobject font_name = field("fontName", "Ljava/lang/String;"); + font_name != nullptr) { + env->DeleteLocalRef(font_name); + env->DeleteLocalRef(cls); + throw odr::UnsupportedOperation(); + } + result.font_size = + take(field("fontSize", "Lapp/opendocument/core/Measure;"), + [&](const jobject value) { return measure_from_java(env, value); }); + result.font_weight = + take(field("fontWeight", "Lapp/opendocument/core/FontWeight;"), + [&](const jobject value) { + return enum_from_java(env, value); + }); + result.font_style = + take(field("fontStyle", "Lapp/opendocument/core/FontStyle;"), + [&](const jobject value) { + return enum_from_java(env, value); + }); + result.font_underline = + take(field("fontUnderline", "Ljava/lang/Boolean;"), + [&](const jobject value) { return boolean_from_java(env, value); }); + result.font_line_through = + take(field("fontLineThrough", "Ljava/lang/Boolean;"), + [&](const jobject value) { return boolean_from_java(env, value); }); + result.font_shadow = + take(field("fontShadow", "Ljava/lang/String;"), + [&](const jobject value) -> std::optional { + if (value == nullptr) { + return std::nullopt; + } + return to_string(env, static_cast(value)); + }); + result.font_color = + take(field("fontColor", "Lapp/opendocument/core/Color;"), + [&](const jobject value) { return color_from_java(env, value); }); + result.background_color = + take(field("backgroundColor", "Lapp/opendocument/core/Color;"), + [&](const jobject value) { return color_from_java(env, value); }); + result.font_position = + take(field("fontPosition", "Lapp/opendocument/core/FontPosition;"), + [&](const jobject value) { + return enum_from_java(env, value); + }); + + env->DeleteLocalRef(cls); + return result; +} + odr::DirectionalStyle directional_measure_from_java(JNIEnv *env, jobject value) { odr::DirectionalStyle result; diff --git a/jni/tests/app/opendocument/core/DocumentTest.java b/jni/tests/app/opendocument/core/DocumentTest.java index cb510a82c..620a876db 100644 --- a/jni/tests/app/opendocument/core/DocumentTest.java +++ b/jni/tests/app/opendocument/core/DocumentTest.java @@ -170,6 +170,42 @@ void removeTakesTheElementOut() throws IOException { assertEquals(TestFiles.ODT_TEXT.subList(1, TestFiles.ODT_TEXT.size()), text); } + private static Text firstText(Element element) { + if (element.type() == ElementType.TEXT) { + return element.asText(); + } + for (Element child : element.children()) { + Text found = firstText(child); + if (found != null) { + return found; + } + } + return null; + } + + @Test + void setStyleMarksARun() throws IOException { + Document document = openDocument(); + Text run = firstText(document.rootElement()); + + TextStyle style = new TextStyle(); + style.fontWeight = FontWeight.BOLD; + style.fontSize = new Measure(14, "pt"); + style.backgroundColor = new Color(255, 255, 0); + run.setStyle(style); + + Path path = tempDir.resolve("styled.odt"); + Files.write(path, document.saveToMemory()); + Document reloaded = Odr.open(path.toString()).asDocumentFile().document(); + TextStyle styled = firstText(reloaded.rootElement()).style(); + + assertEquals(FontWeight.BOLD, styled.fontWeight); + assertEquals(14.0, styled.fontSize.magnitude); + assertEquals("pt", styled.fontSize.unit); + assertEquals(new Color(255, 255, 0), styled.backgroundColor); + assertNull(styled.fontStyle); + } + @Test void splitAndMergeAreInverse() throws IOException { Document document = openDocument(); diff --git a/python/src/bind_document.cpp b/python/src/bind_document.cpp index e332b4f71..828ad8f04 100644 --- a/python/src/bind_document.cpp +++ b/python/src/bind_document.cpp @@ -237,6 +237,7 @@ void odr_python::bind_document(py::module_ &m) { bind_element(m, "Text") .def("content", &odr::Text::content) .def("set_content", &odr::Text::set_content, py::arg("text")) + .def("set_style", &odr::Text::set_style, py::arg("style")) .def("style", &odr::Text::style, keep_self_alive); bind_element(m, "Link").def("href", &odr::Link::href); diff --git a/python/tests/test_document.py b/python/tests/test_document.py index 460aba2c6..cf7f56887 100644 --- a/python/tests/test_document.py +++ b/python/tests/test_document.py @@ -197,3 +197,34 @@ def test_split_and_merge_are_inverse(odt_path, tmp_path): text = walk_text(pyodr.open(str(path)).as_document_file().document().root_element()) assert "Hello from pyodr!" in text + + +def first_text(element): + if element.type() == pyodr.ElementType.text: + return element.as_text() + for child in element.children(): + found = first_text(child) + if found is not None: + return found + return None + + +def test_set_style_marks_a_run(odt_path, tmp_path): + document = pyodr.open(str(odt_path)).as_document_file().document() + run = first_text(document.root_element()) + + style = pyodr.TextStyle() + style.font_weight = pyodr.FontWeight.bold + style.font_size = pyodr.Measure("14pt") + style.background_color = pyodr.Color(0xFF, 0xFF, 0x00) + run.set_style(style) + + path = tmp_path / "styled.odt" + path.write_bytes(document.save_to_memory()) + reloaded = pyodr.open(str(path)).as_document_file().document() + styled = first_text(reloaded.root_element()).style() + + assert styled.font_weight == pyodr.FontWeight.bold + assert styled.font_size == pyodr.Measure("14pt") + assert styled.background_color.rgb() == 0xFFFF00 + assert styled.font_style is None diff --git a/wasm/js/index.d.ts b/wasm/js/index.d.ts index 89d0577ab..5f8d725f4 100644 --- a/wasm/js/index.d.ts +++ b/wasm/js/index.d.ts @@ -17,6 +17,20 @@ export interface EnumTables { LogLevel: Record; } +/** What `Document.setTextStyle` states on a run: the seven properties of + * `docs/design/document-editing.md`, a toggle as a bool, a colour as + * `#rrggbb`, a size as a length with a fixed size (`14pt`). */ +export interface TextStyle { + bold?: boolean; + italic?: boolean; + underline?: boolean; + strikethrough?: boolean; + /** `null` takes a highlight away. */ + highlight?: string | null; + color?: string; + size?: string; +} + export interface Capabilities { detectByContent: boolean; open: boolean; @@ -178,6 +192,13 @@ export declare class Document { insertTextBefore(anchorId: number, text: string): number; insertTextAfter(anchorId: number, text: string): number; appendText(parentId: number, text: string): number; + /** + * States `style` on one run and leaves what it does not name. A property + * set is written, never removed; `highlight: null` takes a highlight away. + * The same object the page's `odr.editing.format` takes. + * @throws OdrError `invalid_argument` for a property it does not know + */ + setTextStyle(id: number, style: TextStyle): this; /** `afterId` of 0 splits before every child. */ splitParagraph(paragraphId: number, afterId?: number): number; mergeParagraphWithNext(paragraphId: number): this; diff --git a/wasm/js/index.js b/wasm/js/index.js index d577c576b..c982d3e3d 100644 --- a/wasm/js/index.js +++ b/wasm/js/index.js @@ -113,6 +113,13 @@ export class Document { return unwrap(this.#core.appendText(this.#handle, parentId, text)); } + // States `style` - `{bold, italic, underline, strikethrough, highlight, + // color, size}`, as the page's `odr.editing.format` takes it - on one run. + setTextStyle(id, style) { + unwrap(this.#core.setTextStyle(this.#handle, id, style)); + return this; + } + // `afterId` of 0 splits before every child. splitParagraph(paragraphId, afterId = 0) { return unwrap(this.#core.splitParagraph(this.#handle, paragraphId, afterId)); diff --git a/wasm/src/wasm_document.cpp b/wasm/src/wasm_document.cpp index 47732e81e..9bb8422eb 100644 --- a/wasm/src/wasm_document.cpp +++ b/wasm/src/wasm_document.cpp @@ -79,6 +79,25 @@ emscripten::val append_text(const Handle handle, const double parent, }); } +/// @p style as the page spells it - `{bold: true, highlight: null, size: +/// "14pt"}` - which is the envelope's own shape, so the envelope parses it. +emscripten::val set_text_style(const Handle handle, const double id, + const emscripten::val style) { + return guarded([&] { + Session &s = session(handle); + if (style.isUndefined() || style.isNull() || + style.typeOf().as() != "object") { + throw std::invalid_argument("setTextStyle takes a style object"); + } + const std::string json = + emscripten::val::global("JSON").call("stringify", style); + document_of(s).edit(R"({"version":2,"ops":[{"op":"setTextStyle","id":)" + + std::to_string(element_of(s, id).identifier()) + + R"(,"style":)" + json + "}]}"); + return ok(); + }); +} + /// @p after of 0 is `null_element_id`: split before every child. emscripten::val split_paragraph(const Handle handle, const double paragraph, const double after) { @@ -138,6 +157,7 @@ EMSCRIPTEN_BINDINGS(odr_document) { emscripten::function("insertTextBefore", &odr::wasm::insert_text_before); emscripten::function("insertTextAfter", &odr::wasm::insert_text_after); emscripten::function("appendText", &odr::wasm::append_text); + emscripten::function("setTextStyle", &odr::wasm::set_text_style); emscripten::function("splitParagraph", &odr::wasm::split_paragraph); emscripten::function("mergeParagraphWithNext", &odr::wasm::merge_paragraph_with_next); diff --git a/wasm/tests/edit.test.mjs b/wasm/tests/edit.test.mjs index b036db410..ee1e732ec 100644 --- a/wasm/tests/edit.test.mjs +++ b/wasm/tests/edit.test.mjs @@ -109,6 +109,37 @@ describe('edit', () => { } }); + it('marks a run by id and saves the mark', () => { + const doc = odr.open(minimalOdt('hello'), { editable: true }); + try { + const id = firstEditableRunId(doc.render(0).html); + doc.setTextStyle(id, { bold: true, highlight: '#ffff00' }); + assert.match(doc.render(0).html, /font-weight:bold/); + + const reopened = odr.open(doc.save()); + try { + const html = reopened.render(0).html; + assert.match(html, /font-weight:bold/); + assert.match(html, /background-color:#ffff00/); + } finally { + reopened.close(); + } + } finally { + doc.close(); + } + }); + + it('refuses a style property it does not know', () => { + const doc = odr.open(minimalOdt('hello'), { editable: true }); + try { + const id = firstEditableRunId(doc.render(0).html); + assert.throws(() => doc.setTextStyle(id, { blink: true }), OdrError); + assert.throws(() => doc.setTextStyle(id, 'bold'), OdrError); + } finally { + doc.close(); + } + }); + it('removes an element by id', () => { const doc = odr.open(minimalOdt('hello'), { editable: true }); try {