From c93585c887d9db607ea559aa9b993444f9916598 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Mon, 28 Sep 2026 20:28:55 +0200 Subject: [PATCH 1/3] feat(bindings): Sheet::set_cell_style in python, java, objective-c and npm Python binds Sheet.set_cell_style as it stands. Java and Objective-C get Sheet.setCellStyle and -[ODRSheet setCellStyle:textStyle:atColumn:row: error:], with TableCellStyle constructible and its fields writable, so a caller builds the delta. A padding or a border is refused before it reaches C++. The npm package's setCellStyle(sheet, column, row, style) takes the object that the page's odr.editing.format takes, and replays it through the envelope, so the parser of the wire checks it. The Java and Swift suites take ods/span.ods from OpenDocument.test as a fixture beside mixed-layout.odt. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01NXnz6EZY8YpyiyyE1GpsWy --- CHANGELOG.md | 6 ++ .../include/OdrCoreObjC/ODRDocumentElement.h | 10 +++ apple/include/OdrCoreObjC/ODRStyle.h | 16 ++--- apple/src/ODRDocumentElement.mm | 12 ++++ apple/src/ODRPrivate.h | 3 + apple/src/ODRStyle.mm | 38 +++++++++++ apple/tests/Fixture.swift | 5 ++ apple/tests/Fixtures/span.ods | Bin 0 -> 8419 bytes apple/tests/OdrCoreTests.swift | 47 +++++++++++++ docs/design/spreadsheet-editing.md | 5 +- jni/CMakeLists.txt | 1 + jni/java/app/opendocument/core/Sheet.java | 12 ++++ .../app/opendocument/core/TableCellStyle.java | 24 ++++--- jni/src/jni_convert.hpp | 3 + jni/src/jni_document.cpp | 14 ++++ jni/src/jni_style.cpp | 63 ++++++++++++++++++ .../app/opendocument/core/TestFiles.java | 15 +++++ .../resources/app/opendocument/core/span.ods | Bin 0 -> 8419 bytes .../app/opendocument/core/DocumentTest.java | 36 ++++++++++ python/src/bind_document.cpp | 4 +- python/tests/conftest.py | 42 ++++++++++++ python/tests/test_document.py | 33 +++++++++ wasm/js/index.d.ts | 20 ++++++ wasm/js/index.js | 7 ++ wasm/src/wasm_document.cpp | 25 +++++++ wasm/tests/edit.test.mjs | 29 ++++++++ 26 files changed, 451 insertions(+), 19 deletions(-) create mode 100644 apple/tests/Fixtures/span.ods create mode 100644 jni/testfixtures/resources/app/opendocument/core/span.ods diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a5659bfe..347c5d03e 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 +- `Sheet::set_cell_style` in the bindings: `Sheet.set_cell_style` in python, + `Sheet.setCellStyle` in Java, `-[ODRSheet setCellStyle:textStyle:atColumn:row:error:]` + in Objective-C, and `Document.setCellStyle(sheet, column, row, style)` in the + npm package. `TableCellStyle` is constructible with writable fields in Java + and Objective-C. + - The sheet editor formats cells. A selection is a rectangle, spanned by a shift click, a shift arrow, a mouse drag or a header click, and `odr.editing.format` and `toggle` take `fill` and `align` beside the text diff --git a/apple/include/OdrCoreObjC/ODRDocumentElement.h b/apple/include/OdrCoreObjC/ODRDocumentElement.h index 164828485..322cb2dc3 100644 --- a/apple/include/OdrCoreObjC/ODRDocumentElement.h +++ b/apple/include/OdrCoreObjC/ODRDocumentElement.h @@ -147,6 +147,16 @@ NS_SWIFT_NAME(Sheet) - (ODRTableCellStyle *)styleForCellAtColumn:(uint32_t)column row:(uint32_t)row NS_SWIFT_NAME(style(column:row:)); +/// States the non-nil properties of both styles on the cell and leaves the +/// rest: the fill, the horizontal alignment and the text properties +/// `-[ODRText setStyle:error:]` takes. Any other refuses with +/// `ODRErrorUnsupportedOperation`. +- (BOOL)setCellStyle:(ODRTableCellStyle *)cellStyle + textStyle:(ODRTextStyle *)textStyle + atColumn:(uint32_t)column + row:(uint32_t)row + error:(NSError **)error + NS_SWIFT_NAME(setStyle(_:textStyle:column:row:)); @end /// `odr::SheetCell`. diff --git a/apple/include/OdrCoreObjC/ODRStyle.h b/apple/include/OdrCoreObjC/ODRStyle.h index 7642f25c0..7a0121c43 100644 --- a/apple/include/OdrCoreObjC/ODRStyle.h +++ b/apple/include/OdrCoreObjC/ODRStyle.h @@ -222,20 +222,20 @@ NS_SWIFT_NAME(TableRowStyle) NS_SWIFT_NAME(TableCellStyle) @interface ODRTableCellStyle : NSObject /// `ODRHorizontalAlign`, boxed. -@property(nonatomic, readonly, nullable) NSNumber *horizontalAlign; +@property(nonatomic, nullable) NSNumber *horizontalAlign; /// `ODRVerticalAlign`, boxed. -@property(nonatomic, readonly, nullable) NSNumber *verticalAlign; -/// `ODRColor`, boxed in an `NSValue`. -@property(nonatomic, readonly, nullable) NSValue *backgroundColor; +@property(nonatomic, nullable) NSNumber *verticalAlign; +/// `ODRColor`, boxed in an `NSValue`; an alpha of 0 takes a fill away. +@property(nonatomic, nullable) NSValue *backgroundColor; @property(nonatomic, readonly) ODRDirectionalMeasure *padding; @property(nonatomic, readonly) ODRDirectionalString *border; /// `double`, boxed. -@property(nonatomic, readonly, nullable) NSNumber *textRotation; +@property(nonatomic, nullable) NSNumber *textRotation; /// `BOOL`, boxed. -@property(nonatomic, readonly, nullable) NSNumber *wrapText; +@property(nonatomic, nullable) NSNumber *wrapText; -- (instancetype)init NS_UNAVAILABLE; -+ (instancetype)new NS_UNAVAILABLE; +/// Every property `nil`, and no side of `padding` or `border` stated. +- (instancetype)init; @end /// Graphic style — `odr::GraphicStyle`. diff --git a/apple/src/ODRDocumentElement.mm b/apple/src/ODRDocumentElement.mm index b2a93586e..0f62b93d3 100644 --- a/apple/src/ODRDocumentElement.mm +++ b/apple/src/ODRDocumentElement.mm @@ -390,6 +390,18 @@ - (ODRTableCellStyle *)styleForCellAtColumn:(uint32_t)column row:(uint32_t)row { nil); } +- (BOOL)setCellStyle:(ODRTableCellStyle *)cellStyle + textStyle:(ODRTextStyle *)textStyle + atColumn:(uint32_t)column + row:(uint32_t)row + error:(NSError **)error { + return guarded(error, [&] { + self.handle.as_sheet().set_cell_style(column, row, [cellStyle handle], + [textStyle handle]); + return YES; + }); +} + @end @implementation ODRSheetCell diff --git a/apple/src/ODRPrivate.h b/apple/src/ODRPrivate.h index 54aedf14d..c6f6ab873 100644 --- a/apple/src/ODRPrivate.h +++ b/apple/src/ODRPrivate.h @@ -125,6 +125,9 @@ NS_ASSUME_NONNULL_BEGIN @interface ODRTableCellStyle (Private) + (instancetype)styleWithHandle:(const odr::TableCellStyle &)handle; +/// The set properties as a `TableCellStyle`; throws `UnsupportedOperation` +/// where `padding` or `border` states a side, which no engine writes. +- (odr::TableCellStyle)handle; @end @interface ODRDrawingPath (Private) diff --git a/apple/src/ODRStyle.mm b/apple/src/ODRStyle.mm index 5e2b7fb09..99632695f 100644 --- a/apple/src/ODRStyle.mm +++ b/apple/src/ODRStyle.mm @@ -3,6 +3,7 @@ #import "ODRInternal.h" #import "ODRPrivate.h" +#include #include #include @@ -306,6 +307,43 @@ + (instancetype)styleWithHandle:(const odr::TableRowStyle &)handle { @implementation ODRTableCellStyle +- (instancetype)init { + if ((self = [super init]) != nil) { + _padding = [ODRDirectionalMeasure + directionalWithHandle:odr::DirectionalStyle()]; + _border = [ODRDirectionalString + directionalWithHandle:odr::DirectionalStyle()]; + } + return self; +} + +- (odr::TableCellStyle)handle { + if (_padding.right != nil || _padding.top != nil || _padding.left != nil || + _padding.bottom != nil || _border.right != nil || _border.top != nil || + _border.left != nil || _border.bottom != nil) { + throw odr::UnsupportedOperation(); + } + odr::TableCellStyle result; + if (_horizontalAlign != nil) { + result.horizontal_align = + static_cast(_horizontalAlign.integerValue); + } + if (_verticalAlign != nil) { + result.vertical_align = + static_cast(_verticalAlign.integerValue); + } + if (_backgroundColor != nil) { + result.background_color = unbox_color(_backgroundColor); + } + if (_textRotation != nil) { + result.text_rotation = _textRotation.doubleValue; + } + if (_wrapText != nil) { + result.wrap_text = _wrapText.boolValue != NO; + } + return result; +} + + (instancetype)styleWithHandle:(const odr::TableCellStyle &)handle { ODRTableCellStyle *const result = [[ODRTableCellStyle alloc] init]; result->_horizontalAlign = box_enum(handle.horizontal_align); diff --git a/apple/tests/Fixture.swift b/apple/tests/Fixture.swift index fe5ba4ac4..1afe2645f 100644 --- a/apple/tests/Fixture.swift +++ b/apple/tests/Fixture.swift @@ -19,6 +19,11 @@ enum Fixture { try path("mixed-layout", "odt") } + /// `ods/span.ods` from the same repository; A1 holds "a". + static func ods() throws -> String { + try path("span", "ods") + } + /// A one-page pdf written to a temporary file, its cross-reference offsets /// computed so they are right. static func pdf() throws -> String { diff --git a/apple/tests/Fixtures/span.ods b/apple/tests/Fixtures/span.ods new file mode 100644 index 0000000000000000000000000000000000000000..c52670de1dd5ad7078bd3902d5b630e5d0432990 GIT binary patch literal 8419 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.size) + XCTAssertEqual([fill.red, fill.green, fill.blue], [255, 255, 0]) + let cell = try XCTUnwrap(reloadedSheet.cell(column: 0, row: 0)) + let text = try XCTUnwrap(cell.firstDescendant(ofType: Text.self)) + XCTAssertEqual(text.style.fontWeight?.intValue, FontWeight.bold.rawValue) + } + + func testSetCellStyleRefusesWhatNoEngineWrites() throws { + let document = try DecodedFile.decode(path: try Fixture.ods()) + .asDocumentFile().document() + let root = try XCTUnwrap(try document.rootElement()) + let sheet = try XCTUnwrap(root.firstDescendant(ofType: Sheet.self)) + + let cellStyle = TableCellStyle() + cellStyle.wrapText = true + XCTAssertThrowsError( + try sheet.setStyle(cellStyle, textStyle: TextStyle(), column: 0, row: 0) + ) { 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/spreadsheet-editing.md b/docs/design/spreadsheet-editing.md index 202a9469b..56187c245 100644 --- a/docs/design/spreadsheet-editing.md +++ b/docs/design/spreadsheet-editing.md @@ -195,8 +195,9 @@ separate scripts. The coordinates are the ones an op names, never a DOM index. ## Cell formatting -Status: `.ods` and `.xlsx` write a cell style, and the sheet editor formats a -selection. The steps land as a stack, in this order: +Status: landed. `.ods` and `.xlsx` write a cell style, the sheet editor +formats a selection, and the bindings take `Sheet::set_cell_style`. The steps +landed as a stack, in this order: 1. The xlsx reader reads what the writer writes: a solid fill from `fgColor`, theme colours with their `tint`, italic, underline and strikethrough, and diff --git a/jni/CMakeLists.txt b/jni/CMakeLists.txt index 434e97edf..84a7ba2e4 100644 --- a/jni/CMakeLists.txt +++ b/jni/CMakeLists.txt @@ -211,6 +211,7 @@ if (ODR_TEST AND NOT ANDROID) # verbatim, where an absolute path is not found. RESOURCES NAMESPACE "app/opendocument/core" "testfixtures/resources/app/opendocument/core/mixed-layout.odt" + "testfixtures/resources/app/opendocument/core/span.ods" INCLUDE_JARS odr_java "${ODR_JNI_JUNIT_JAR}" OUTPUT_NAME odr-core-java-tests ) diff --git a/jni/java/app/opendocument/core/Sheet.java b/jni/java/app/opendocument/core/Sheet.java index ef6d026b8..d5300af5d 100644 --- a/jni/java/app/opendocument/core/Sheet.java +++ b/jni/java/app/opendocument/core/Sheet.java @@ -54,6 +54,15 @@ public TableCellStyle cellStyle(int column, int row) { return cellStyleNative(handle(), column, row); } + /** + * States the non-null fields of both styles on the cell and leaves the rest. The fill, the + * horizontal alignment and the text keys of {@link Text#setStyle} are written; any other field + * throws {@link OdrException.UnsupportedOperation}. + */ + public void setCellStyle(int column, int row, TableCellStyle cellStyle, TextStyle textStyle) { + setCellStyleNative(handle(), column, row, cellStyle, textStyle); + } + private native String nameNative(long handle); private native PageLayout pageLayoutNative(long handle); @@ -73,4 +82,7 @@ public TableCellStyle cellStyle(int column, int row) { private native TableRowStyle rowStyleNative(long handle, int row); private native TableCellStyle cellStyleNative(long handle, int column, int row); + + private native void setCellStyleNative( + long handle, int column, int row, TableCellStyle cellStyle, TextStyle textStyle); } diff --git a/jni/java/app/opendocument/core/TableCellStyle.java b/jni/java/app/opendocument/core/TableCellStyle.java index f70b4c0fb..de924fcfe 100644 --- a/jni/java/app/opendocument/core/TableCellStyle.java +++ b/jni/java/app/opendocument/core/TableCellStyle.java @@ -1,14 +1,22 @@ package app.opendocument.core; -/** Style of a table cell. Mirrors {@code odr::TableCellStyle}; fields may be {@code null}. */ +/** + * Style of a table cell. Mirrors {@code odr::TableCellStyle}; a {@code null} field is one the + * document does not state. A caller builds one for {@link Sheet#setCellStyle}: every field left + * {@code null} is left alone on the cell. + */ public final class TableCellStyle { - public final HorizontalAlign horizontalAlign; - public final VerticalAlign verticalAlign; - public final Color backgroundColor; - public final DirectionalMeasure padding; - public final DirectionalString border; - public final Double textRotation; - public final Boolean wrapText; + public HorizontalAlign horizontalAlign; + public VerticalAlign verticalAlign; + /** An alpha of 0 takes a fill away. */ + public Color backgroundColor; + public DirectionalMeasure padding; + public DirectionalString border; + public Double textRotation; + public Boolean wrapText; + + /** Every field {@code null}. */ + public TableCellStyle() {} TableCellStyle( int horizontalAlign, diff --git a/jni/src/jni_convert.hpp b/jni/src/jni_convert.hpp index 9ecb376b2..8e6ef6bb7 100644 --- a/jni/src/jni_convert.hpp +++ b/jni/src/jni_convert.hpp @@ -55,6 +55,9 @@ 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. odr::TextStyle text_style_from_java(JNIEnv *env, jobject style); +/// The fields a Java `TableCellStyle` states; a padding or a border is +/// refused, since no engine writes one. +odr::TableCellStyle table_cell_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 6dc0b4032..d1083ceac 100644 --- a/jni/src/jni_document.cpp +++ b/jni/src/jni_document.cpp @@ -503,6 +503,20 @@ Java_app_opendocument_core_Sheet_cellStyleNative(JNIEnv *env, jobject, }); } +extern "C" JNIEXPORT void JNICALL +Java_app_opendocument_core_Sheet_setCellStyleNative(JNIEnv *env, jobject, + jlong handle, jint column, + jint row, + jobject cell_style, + jobject text_style) { + guarded(env, [&] { + element(handle).as_sheet().set_cell_style( + static_cast(column), static_cast(row), + odr_jni::table_cell_style_from_java(env, cell_style), + odr_jni::text_style_from_java(env, text_style)); + }); +} + // app.opendocument.core.SheetCell extern "C" JNIEXPORT jobject JNICALL diff --git a/jni/src/jni_style.cpp b/jni/src/jni_style.cpp index 226939193..5765b9182 100644 --- a/jni/src/jni_style.cpp +++ b/jni/src/jni_style.cpp @@ -1,7 +1,10 @@ #include "jni_convert.hpp" #include "odr_jni.hpp" +#include + #include +#include #include namespace odr_jni { @@ -339,6 +342,66 @@ odr::TextStyle text_style_from_java(JNIEnv *env, const jobject style) { return result; } +odr::TableCellStyle table_cell_style_from_java(JNIEnv *env, + const jobject style) { + odr::TableCellStyle 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; + }; + + for (const auto &[name, signature] : + {std::pair{"padding", "Lapp/opendocument/core/DirectionalMeasure;"}, + std::pair{"border", "Lapp/opendocument/core/DirectionalString;"}}) { + if (const jobject value = field(name, signature); value != nullptr) { + env->DeleteLocalRef(value); + env->DeleteLocalRef(cls); + throw odr::UnsupportedOperation(); + } + } + result.horizontal_align = + take(field("horizontalAlign", "Lapp/opendocument/core/HorizontalAlign;"), + [&](const jobject value) { + return enum_from_java(env, value); + }); + result.vertical_align = + take(field("verticalAlign", "Lapp/opendocument/core/VerticalAlign;"), + [&](const jobject value) { + return enum_from_java(env, value); + }); + result.background_color = + take(field("backgroundColor", "Lapp/opendocument/core/Color;"), + [&](const jobject value) { return color_from_java(env, value); }); + result.text_rotation = + take(field("textRotation", "Ljava/lang/Double;"), + [&](const jobject value) -> std::optional { + if (value == nullptr) { + return std::nullopt; + } + jclass boxed = env->GetObjectClass(value); + const jdouble unboxed = env->CallDoubleMethod( + value, env->GetMethodID(boxed, "doubleValue", "()D")); + env->DeleteLocalRef(boxed); + return unboxed; + }); + result.wrap_text = + take(field("wrapText", "Ljava/lang/Boolean;"), + [&](const jobject value) { return boolean_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/testfixtures/app/opendocument/core/TestFiles.java b/jni/testfixtures/app/opendocument/core/TestFiles.java index b108c6cc8..65d6770c4 100644 --- a/jni/testfixtures/app/opendocument/core/TestFiles.java +++ b/jni/testfixtures/app/opendocument/core/TestFiles.java @@ -27,6 +27,9 @@ final class TestFiles { static final String ODT_RESOURCE = "mixed-layout.odt"; + /** {@code ods/span.ods} from the same repository; A1 holds "a". */ + static final String ODS_RESOURCE = "span.ods"; + /** * The text of the document, node by node in document order. Each paragraph is a run and a span, * so the numbers are their own text elements — and the runs keep their trailing space. @@ -55,6 +58,18 @@ static Path odtFile(Path directory) throws IOException { return path; } + /** The OpenDocument spreadsheet, unpacked from the classpath into {@code directory}. */ + static Path odsFile(Path directory) throws IOException { + Path path = directory.resolve(ODS_RESOURCE); + try (InputStream stream = TestFiles.class.getResourceAsStream(ODS_RESOURCE)) { + if (stream == null) { + throw new IOException(ODS_RESOURCE + " is missing from the test classpath"); + } + Files.copy(stream, path, StandardCopyOption.REPLACE_EXISTING); + } + return path; + } + static Path csvFile(Path directory) throws IOException { Path path = directory.resolve("table.csv"); write(path, "name,value\nalpha,1\nbeta,2\n"); diff --git a/jni/testfixtures/resources/app/opendocument/core/span.ods b/jni/testfixtures/resources/app/opendocument/core/span.ods new file mode 100644 index 0000000000000000000000000000000000000000..c52670de1dd5ad7078bd3902d5b630e5d0432990 GIT binary patch literal 8419 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 sheet.setCellStyle(0, 0, cellStyle, new TextStyle())); + } + @Test void splitAndMergeAreInverse() throws IOException { Document document = openDocument(); diff --git a/python/src/bind_document.cpp b/python/src/bind_document.cpp index c8a0327ac..6c2a40cf0 100644 --- a/python/src/bind_document.cpp +++ b/python/src/bind_document.cpp @@ -190,7 +190,9 @@ void odr_python::bind_document(py::module_ &m) { .def("column_style", &odr::Sheet::column_style, py::arg("column")) .def("row_style", &odr::Sheet::row_style, py::arg("row")) .def("cell_style", &odr::Sheet::cell_style, py::arg("column"), - py::arg("row")); + py::arg("row")) + .def("set_cell_style", &odr::Sheet::set_cell_style, py::arg("column"), + py::arg("row"), py::arg("cell_style"), py::arg("text_style")); bind_element(m, "SheetCell") .def("position", &odr::SheetCell::position) diff --git a/python/tests/conftest.py b/python/tests/conftest.py index 2691a2f10..f4b257661 100644 --- a/python/tests/conftest.py +++ b/python/tests/conftest.py @@ -57,6 +57,48 @@ """ +ODS_CONTENT_XML = """ + + + + + + + cell + + + + + + +""" + + +@pytest.fixture +def ods_path(tmp_path): + """A minimal OpenDocument spreadsheet of one cell, built from inline XML.""" + path = tmp_path / "minimal.ods" + with zipfile.ZipFile(path, "w") as archive: + archive.writestr( + "mimetype", + "application/vnd.oasis.opendocument.spreadsheet", + compress_type=zipfile.ZIP_STORED, + ) + archive.writestr("content.xml", ODS_CONTENT_XML) + archive.writestr("styles.xml", ODT_STYLES_XML) + archive.writestr( + "META-INF/manifest.xml", + ODT_MANIFEST_XML.replace( + "opendocument.text", "opendocument.spreadsheet" + ), + ) + return path + + @pytest.fixture def odt_path(tmp_path): """A minimal OpenDocument text file built from inline XML.""" diff --git a/python/tests/test_document.py b/python/tests/test_document.py index cf7f56887..0ecb92a97 100644 --- a/python/tests/test_document.py +++ b/python/tests/test_document.py @@ -1,4 +1,5 @@ import pyodr +import pytest def walk_text(element): @@ -228,3 +229,35 @@ def test_set_style_marks_a_run(odt_path, tmp_path): assert styled.font_size == pyodr.Measure("14pt") assert styled.background_color.rgb() == 0xFFFF00 assert styled.font_style is None + + +def test_set_cell_style_fills_a_cell(ods_path, tmp_path): + document = pyodr.open(str(ods_path)).as_document_file().document() + sheet = next(iter(document.root_element().children())).as_sheet() + + cell_style = pyodr.TableCellStyle() + cell_style.background_color = pyodr.Color(0xFF, 0xFF, 0x00) + cell_style.horizontal_align = pyodr.HorizontalAlign.center + text_style = pyodr.TextStyle() + text_style.font_weight = pyodr.FontWeight.bold + sheet.set_cell_style(0, 0, cell_style, text_style) + + path = tmp_path / "styled.ods" + path.write_bytes(document.save_to_memory()) + reloaded = pyodr.open(str(path)).as_document_file().document() + reloaded_sheet = next(iter(reloaded.root_element().children())).as_sheet() + + assert reloaded_sheet.cell_style(0, 0).background_color.rgb() == 0xFFFF00 + assert first_text(reloaded_sheet.cell(0, 0)).style().font_weight == ( + pyodr.FontWeight.bold + ) + + +def test_set_cell_style_refuses_what_no_engine_writes(ods_path): + document = pyodr.open(str(ods_path)).as_document_file().document() + sheet = next(iter(document.root_element().children())).as_sheet() + + cell_style = pyodr.TableCellStyle() + cell_style.wrap_text = True + with pytest.raises(Exception): + sheet.set_cell_style(0, 0, cell_style, pyodr.TextStyle()) diff --git a/wasm/js/index.d.ts b/wasm/js/index.d.ts index a20106256..b5851b3b9 100644 --- a/wasm/js/index.d.ts +++ b/wasm/js/index.d.ts @@ -31,6 +31,20 @@ export interface TextStyle { size?: string; } +/** What `Document.setCellStyle` states on a cell: the keys of `TextStyle` + * but `highlight`, and the cell's own ground and alignment. */ +export interface CellStyle { + bold?: boolean; + italic?: boolean; + underline?: boolean; + strikethrough?: boolean; + color?: string; + size?: string; + /** `null` takes a fill away. */ + fill?: string | null; + align?: 'left' | 'center' | 'right'; +} + export interface Capabilities { detectByContent: boolean; open: boolean; @@ -200,6 +214,12 @@ export declare class Document { * @throws OdrError `invalid_argument` for a property it does not know */ setTextStyle(id: number, style: TextStyle): this; + /** + * States `style` on the cell at (`column`, `row`) of the `sheet`-th sheet + * and leaves what it does not name. + * @throws OdrError `invalid_argument` for a property it does not know + */ + setCellStyle(sheet: number, column: number, row: number, style: CellStyle): 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 c982d3e3d..39c7b56fa 100644 --- a/wasm/js/index.js +++ b/wasm/js/index.js @@ -120,6 +120,13 @@ export class Document { return this; } + // States `style` - the text keys of `setTextStyle`, and `fill` and + // `align` - on the cell at a position, as `odr.editing.format` takes it. + setCellStyle(sheet, column, row, style) { + unwrap(this.#core.setCellStyle(this.#handle, sheet, column, row, 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 129dbf266..1d17db51f 100644 --- a/wasm/src/wasm_document.cpp +++ b/wasm/src/wasm_document.cpp @@ -7,6 +7,7 @@ #include +#include #include #include #include @@ -107,6 +108,29 @@ emscripten::val set_text_style(const Handle handle, const double id, }); } +/// @p style as the page's `odr.editing.format` takes it for a cell, replayed +/// through the envelope, which parses it. +emscripten::val set_cell_style(const Handle handle, const double sheet, + const double column, const double row, + const emscripten::val style) { + return guarded([&] { + Session &s = session(handle); + if (style.isUndefined() || style.isNull() || + style.typeOf().as() != "object") { + throw std::invalid_argument("setCellStyle takes a style object"); + } + const std::string json = + emscripten::val::global("JSON").call("stringify", style); + document_of(s).edit( + R"({"version":2,"ops":[{"op":"setCellStyle","sheet":)" + + std::to_string(static_cast(sheet)) + R"(,"column":)" + + std::to_string(static_cast(column)) + R"(,"row":)" + + std::to_string(static_cast(row)) + 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) { @@ -176,6 +200,7 @@ EMSCRIPTEN_BINDINGS(odr_document) { 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("setCellStyle", &odr::wasm::set_cell_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 677c7aa4f..43cb9de22 100644 --- a/wasm/tests/edit.test.mjs +++ b/wasm/tests/edit.test.mjs @@ -84,6 +84,35 @@ describe('edit', () => { } }); + it('styles a sheet cell by position and saves the style', () => { + const doc = odr.open(minimalOds('hello')); + try { + doc.setCellStyle(0, 0, 0, { fill: '#ffff00', bold: true }); + assert.match(doc.render(0).html, /background-color:#ffff00/); + + const reopened = odr.open(doc.save()); + try { + const html = reopened.render(0).html; + assert.match(html, /background-color:#ffff00/); + assert.match(html, /font-weight:bold/); + } finally { + reopened.close(); + } + } finally { + doc.close(); + } + }); + + it('refuses a cell style it cannot write', () => { + const doc = odr.open(minimalOds('hello')); + try { + assert.throws(() => doc.setCellStyle(0, 0, 0, { highlight: '#ffff00' }), OdrError); + assert.throws(() => doc.setCellStyle(0, 0, 0, 'bold'), OdrError); + } finally { + doc.close(); + } + }); + it('edits structurally by id and saves the result', () => { const doc = odr.open(minimalOdt('hello'), { editable: true }); try { From 21ae711c80d8ba3fa9de7887447e6caba87431cc Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Mon, 28 Sep 2026 20:29:25 +0200 Subject: [PATCH 2/3] fix(test): store the ods fixture as binary The text eol rule rewrote its CRLF bytes, which breaks the zip on a fresh checkout. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01NXnz6EZY8YpyiyyE1GpsWy --- .gitattributes | 1 + apple/tests/Fixtures/span.ods | Bin 8419 -> 8420 bytes .../resources/app/opendocument/core/span.ods | Bin 8419 -> 8420 bytes 3 files changed, 1 insertion(+) diff --git a/.gitattributes b/.gitattributes index dea0bbb8b..78683bbd7 100644 --- a/.gitattributes +++ b/.gitattributes @@ -18,3 +18,4 @@ # the test fixtures the bindings carry: a zip container, which `* text eol=lf` # above would happily rewrite into an archive nothing can open *.odt binary +*.ods binary diff --git a/apple/tests/Fixtures/span.ods b/apple/tests/Fixtures/span.ods index c52670de1dd5ad7078bd3902d5b630e5d0432990..7730112e58d7586eff889f30a32aa1bc49d025e6 100644 GIT binary patch delta 14 VcmaFt_{4ETgE%Aa<_7U(P5>}K1yuk5 delta 12 TcmaFj_}FnngZSn~@nlW_D9;5* diff --git a/jni/testfixtures/resources/app/opendocument/core/span.ods b/jni/testfixtures/resources/app/opendocument/core/span.ods index c52670de1dd5ad7078bd3902d5b630e5d0432990..7730112e58d7586eff889f30a32aa1bc49d025e6 100644 GIT binary patch delta 14 VcmaFt_{4ETgE%Aa<_7U(P5>}K1yuk5 delta 12 TcmaFj_}FnngZSn~@nlW_D9;5* From 5f61f1b29f356423a5ca996bfdc376e42714c2a9 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Mon, 28 Sep 2026 21:43:35 +0200 Subject: [PATCH 3/3] refactor(bindings): tighten the cell style test and type The python refusal test now expects `pyodr.UnsupportedOperation`, so a wrong error no longer passes it. The npm `CellStyle` builds on `TextStyle` without `highlight` and does not repeat its keys. The changelog entry is shorter. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Tigcf9he3w1aHfNPJoEGsd --- CHANGELOG.md | 8 +++----- python/tests/test_document.py | 2 +- wasm/js/index.d.ts | 8 +------- 3 files changed, 5 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 347c5d03e..357b7bcee 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 -- `Sheet::set_cell_style` in the bindings: `Sheet.set_cell_style` in python, - `Sheet.setCellStyle` in Java, `-[ODRSheet setCellStyle:textStyle:atColumn:row:error:]` - in Objective-C, and `Document.setCellStyle(sheet, column, row, style)` in the - npm package. `TableCellStyle` is constructible with writable fields in Java - and Objective-C. +- The python, Java, Objective-C and npm bindings style a sheet cell + (`Sheet::set_cell_style`). In Java and Objective-C, a `TableCellStyle` can + now be built and written. - The sheet editor formats cells. A selection is a rectangle, spanned by a shift click, a shift arrow, a mouse drag or a header click, and diff --git a/python/tests/test_document.py b/python/tests/test_document.py index 0ecb92a97..5cc043a19 100644 --- a/python/tests/test_document.py +++ b/python/tests/test_document.py @@ -259,5 +259,5 @@ def test_set_cell_style_refuses_what_no_engine_writes(ods_path): cell_style = pyodr.TableCellStyle() cell_style.wrap_text = True - with pytest.raises(Exception): + with pytest.raises(pyodr.UnsupportedOperation): sheet.set_cell_style(0, 0, cell_style, pyodr.TextStyle()) diff --git a/wasm/js/index.d.ts b/wasm/js/index.d.ts index b5851b3b9..f39b448bb 100644 --- a/wasm/js/index.d.ts +++ b/wasm/js/index.d.ts @@ -33,13 +33,7 @@ export interface TextStyle { /** What `Document.setCellStyle` states on a cell: the keys of `TextStyle` * but `highlight`, and the cell's own ground and alignment. */ -export interface CellStyle { - bold?: boolean; - italic?: boolean; - underline?: boolean; - strikethrough?: boolean; - color?: string; - size?: string; +export interface CellStyle extends Omit { /** `null` takes a fill away. */ fill?: string | null; align?: 'left' | 'center' | 'right';