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/CHANGELOG.md b/CHANGELOG.md index 4a5659bfe..357b7bcee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ The release run heads these entries with the version and opens a fresh ## Unreleased +- 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 `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 000000000..7730112e5 Binary files /dev/null and b/apple/tests/Fixtures/span.ods differ diff --git a/apple/tests/OdrCoreTests.swift b/apple/tests/OdrCoreTests.swift index 5412b68a6..f32a66864 100644 --- a/apple/tests/OdrCoreTests.swift +++ b/apple/tests/OdrCoreTests.swift @@ -420,6 +420,53 @@ final class DocumentSaveTests: XCTestCase { } } + func testSetCellStyleFillsACell() 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() + var yellow = ODRColor(red: 255, green: 255, blue: 0, alpha: 255) + cellStyle.backgroundColor = NSValue(bytes: &yellow, objCType: "{ODRColor=CCCC}") + cellStyle.horizontalAlign = NSNumber(value: HorizontalAlign.center.rawValue) + let textStyle = TextStyle() + textStyle.fontWeight = NSNumber(value: FontWeight.bold.rawValue) + try sheet.setStyle(cellStyle, textStyle: textStyle, column: 0, row: 0) + + let saved = try XCTUnwrap(try document.saveToMemory()) + let path = URL(fileURLWithPath: try temporaryDirectory()) + .appendingPathComponent("styled.ods") + try saved.write(to: path) + + let reloaded = try DecodedFile.decode(path: path.path) + .asDocumentFile().document() + let reloadedRoot = try XCTUnwrap(try reloaded.rootElement()) + let reloadedSheet = try XCTUnwrap(reloadedRoot.firstDescendant(ofType: Sheet.self)) + var fill = ODRColor() + try XCTUnwrap(reloadedSheet.style(column: 0, row: 0).backgroundColor) + .getValue(&fill, size: MemoryLayout.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 000000000..7730112e5 Binary files /dev/null and b/jni/testfixtures/resources/app/opendocument/core/span.ods differ diff --git a/jni/tests/app/opendocument/core/DocumentTest.java b/jni/tests/app/opendocument/core/DocumentTest.java index 5d06beb4a..615c1fca7 100644 --- a/jni/tests/app/opendocument/core/DocumentTest.java +++ b/jni/tests/app/opendocument/core/DocumentTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.IOException; @@ -191,6 +192,41 @@ void setStyleMarksARun() throws IOException { assertNull(styled.fontStyle); } + @Test + void setCellStyleFillsACell() throws IOException { + Path ods = TestFiles.odsFile(tempDir); + Document document = Odr.open(ods.toString()).asDocumentFile().document(); + Sheet sheet = document.rootElement().firstChild().asSheet(); + + TableCellStyle cellStyle = new TableCellStyle(); + cellStyle.backgroundColor = new Color(255, 255, 0); + cellStyle.horizontalAlign = HorizontalAlign.CENTER; + TextStyle textStyle = new TextStyle(); + textStyle.fontWeight = FontWeight.BOLD; + sheet.setCellStyle(0, 0, cellStyle, textStyle); + + Path path = tempDir.resolve("styled.ods"); + Files.write(path, document.saveToMemory()); + Document reloaded = Odr.open(path.toString()).asDocumentFile().document(); + Sheet reloadedSheet = reloaded.rootElement().firstChild().asSheet(); + + assertEquals(new Color(255, 255, 0), reloadedSheet.cellStyle(0, 0).backgroundColor); + assertEquals(FontWeight.BOLD, firstText(reloadedSheet.cell(0, 0)).style().fontWeight); + } + + @Test + void setCellStyleRefusesWhatNoEngineWrites() throws IOException { + Path ods = TestFiles.odsFile(tempDir); + Document document = Odr.open(ods.toString()).asDocumentFile().document(); + Sheet sheet = document.rootElement().firstChild().asSheet(); + + TableCellStyle cellStyle = new TableCellStyle(); + cellStyle.wrapText = true; + assertThrows( + OdrException.UnsupportedOperation.class, + () -> 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..5cc043a19 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(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 a20106256..f39b448bb 100644 --- a/wasm/js/index.d.ts +++ b/wasm/js/index.d.ts @@ -31,6 +31,14 @@ 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 extends Omit { + /** `null` takes a fill away. */ + fill?: string | null; + align?: 'left' | 'center' | 'right'; +} + export interface Capabilities { detectByContent: boolean; open: boolean; @@ -200,6 +208,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 {