diff --git a/CHANGELOG.md b/CHANGELOG.md index c054a0869..387a4c073 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,14 @@ The release run heads these entries with the version and opens a fresh ## Unreleased +- A file can be read in the dark: `HtmlConfig::color_scheme` is `light`, `dark` + or `system`, following the reader's `prefers-color-scheme`. Every view honors + it but the pdf one, and the colors the file authored give way to it. Bound in + the python, wasm, jni and apple bindings as `HtmlColorScheme`. +- `FileTypeCapabilities::color_scheme` says whether a type's view honors it — + every type that renders but pdf, audio and video. Bound in the bindings too. +- A text document reflowed to the viewport is inset 3mm from the screen edge + rather than starting at the first pixel of it. - Prose is no longer read as a csv. A separator that every field follows with a space, in fields long enough to be sentences, is punctuation; `a, b, c` with short values is still a csv. One record is a line, not a table. diff --git a/apple/include/OdrCoreObjC/ODRFile.h b/apple/include/OdrCoreObjC/ODRFile.h index 337ded16f..d1b070786 100644 --- a/apple/include/OdrCoreObjC/ODRFile.h +++ b/apple/include/OdrCoreObjC/ODRFile.h @@ -129,6 +129,8 @@ NS_SWIFT_NAME(FileTypeCapabilities) @property(nonatomic, readonly) BOOL decrypt; /// `ODRHtml` produces output for it. @property(nonatomic, readonly) BOOL translateHtml; +/// The view it renders as honors `ODRHtmlConfig.colorScheme`. +@property(nonatomic, readonly) BOOL colorScheme; @property(nonatomic, readonly) BOOL edit; @property(nonatomic, readonly) BOOL save; /// Saving with a password is supported. diff --git a/apple/include/OdrCoreObjC/ODRHtml.h b/apple/include/OdrCoreObjC/ODRHtml.h index c93b22a3c..8b46496c1 100644 --- a/apple/include/OdrCoreObjC/ODRHtml.h +++ b/apple/include/OdrCoreObjC/ODRHtml.h @@ -26,6 +26,17 @@ typedef NS_ENUM(NSInteger, ODRHtmlTableGridlines) { ODRHtmlTableGridlinesHard, } NS_SWIFT_NAME(HtmlTableGridlines); +/// The colors the emitted HTML renders against. `ODRFileTypeCapabilities` +/// says which views honor it. +typedef NS_ENUM(NSInteger, ODRHtmlColorScheme) { + /// A white page, carrying the colors the document gives its content. + ODRHtmlColorSchemeLight = 0, + /// A dark page, which the document's own text and fill colors give way to. + ODRHtmlColorSchemeDark, + /// Light or dark, by the reader's `prefers-color-scheme`. + ODRHtmlColorSchemeSystem, +} NS_SWIFT_NAME(HtmlColorScheme); + /// Initial zoom of the emitted HTML on mobile (the viewport meta tag). Desktop /// browsers ignore it entirely. typedef NS_ENUM(NSInteger, ODRHtmlViewportMode) { @@ -71,6 +82,8 @@ NS_SWIFT_NAME(HtmlConfig) @property(nonatomic) BOOL editable; @property(nonatomic) BOOL textDocumentMargin; +@property(nonatomic) ODRHtmlColorScheme colorScheme; + /// `nil` for no limit. @property(nonatomic, strong, nullable) NSValue *spreadsheetLimit; @property(nonatomic) BOOL spreadsheetLimitByContent; diff --git a/apple/src/ODRFile.mm b/apple/src/ODRFile.mm index d1dad972d..472f05f83 100644 --- a/apple/src/ODRFile.mm +++ b/apple/src/ODRFile.mm @@ -156,6 +156,7 @@ + (instancetype)capabilitiesWithHandle: result->_open = handle.open ? YES : NO; result->_decrypt = handle.decrypt ? YES : NO; result->_translateHtml = handle.translate_html ? YES : NO; + result->_colorScheme = handle.color_scheme ? YES : NO; result->_edit = handle.edit ? YES : NO; result->_save = handle.save ? YES : NO; result->_encrypt = handle.encrypt ? YES : NO; diff --git a/apple/src/ODRHtml.mm b/apple/src/ODRHtml.mm index 6881fb8dd..bb8fb3201 100644 --- a/apple/src/ODRHtml.mm +++ b/apple/src/ODRHtml.mm @@ -27,6 +27,10 @@ ODR_SAME_ENUM(ODRHtmlTableGridlinesSoft, odr::HtmlTableGridlines::soft); ODR_SAME_ENUM(ODRHtmlTableGridlinesHard, odr::HtmlTableGridlines::hard); +ODR_SAME_ENUM(ODRHtmlColorSchemeLight, odr::HtmlColorScheme::light); +ODR_SAME_ENUM(ODRHtmlColorSchemeDark, odr::HtmlColorScheme::dark); +ODR_SAME_ENUM(ODRHtmlColorSchemeSystem, odr::HtmlColorScheme::system); + ODR_SAME_ENUM(ODRHtmlViewportModeAutomatic, odr::HtmlViewportMode::automatic); ODR_SAME_ENUM(ODRHtmlViewportModeFitWidth, odr::HtmlViewportMode::fit_width); ODR_SAME_ENUM(ODRHtmlViewportModeActualSize, @@ -84,6 +88,7 @@ - (instancetype)initWithNativeConfig:(const odr::HtmlConfig &)config { _relativeResourcePaths = config.relative_resource_paths ? YES : NO; _editable = config.editable ? YES : NO; _textDocumentMargin = config.text_document_margin ? YES : NO; + _colorScheme = static_cast(config.color_scheme); if (config.spreadsheet_limit.has_value()) { const ODRTableDimensions limit = ODRTableDimensionsMake( config.spreadsheet_limit->rows, config.spreadsheet_limit->columns); @@ -141,6 +146,7 @@ - (instancetype)initWithNativeConfig:(const odr::HtmlConfig &)config { config.relative_resource_paths = _relativeResourcePaths == YES; config.editable = _editable == YES; config.text_document_margin = _textDocumentMargin == YES; + config.color_scheme = static_cast(_colorScheme); if (_spreadsheetLimit != nil) { ODRTableDimensions limit{}; [_spreadsheetLimit getValue:&limit size:sizeof(limit)]; diff --git a/jni/CMakeLists.txt b/jni/CMakeLists.txt index e0f36089a..e5ced466a 100644 --- a/jni/CMakeLists.txt +++ b/jni/CMakeLists.txt @@ -105,6 +105,7 @@ add_jar(odr_java "java/app/opendocument/core/GuardedNativeResource.java" "java/app/opendocument/core/HorizontalAlign.java" "java/app/opendocument/core/Html.java" + "java/app/opendocument/core/HtmlColorScheme.java" "java/app/opendocument/core/HtmlConfig.java" "java/app/opendocument/core/HtmlPage.java" "java/app/opendocument/core/HtmlResource.java" diff --git a/jni/java/app/opendocument/core/FileTypeCapabilities.java b/jni/java/app/opendocument/core/FileTypeCapabilities.java index 8bb1369c2..2a36b7743 100644 --- a/jni/java/app/opendocument/core/FileTypeCapabilities.java +++ b/jni/java/app/opendocument/core/FileTypeCapabilities.java @@ -22,6 +22,9 @@ public final class FileTypeCapabilities { /** {@link Html#translate} produces output. */ public final boolean translateHtml; + /** The view this type renders as honors {@link HtmlConfig#colorScheme}. */ + public final boolean colorScheme; + /** {@link Document#isEditable} can be {@code true}. */ public final boolean edit; @@ -36,6 +39,7 @@ public final class FileTypeCapabilities { boolean open, boolean decrypt, boolean translateHtml, + boolean colorScheme, boolean edit, boolean save, boolean encrypt) { @@ -43,6 +47,7 @@ public final class FileTypeCapabilities { this.open = open; this.decrypt = decrypt; this.translateHtml = translateHtml; + this.colorScheme = colorScheme; this.edit = edit; this.save = save; this.encrypt = encrypt; diff --git a/jni/java/app/opendocument/core/HtmlColorScheme.java b/jni/java/app/opendocument/core/HtmlColorScheme.java new file mode 100644 index 000000000..bdd96409c --- /dev/null +++ b/jni/java/app/opendocument/core/HtmlColorScheme.java @@ -0,0 +1,14 @@ +package app.opendocument.core; + +/** Mirrors {@code odr::HtmlColorScheme}; constant order must match the C++ declaration. */ +public enum HtmlColorScheme { + LIGHT, DARK, SYSTEM; + + static HtmlColorScheme fromNative(int code) { + return code < 0 ? null : values()[code]; + } + + int toNative() { + return ordinal(); + } +} diff --git a/jni/java/app/opendocument/core/HtmlConfig.java b/jni/java/app/opendocument/core/HtmlConfig.java index 7621ef191..4d9c3a61c 100644 --- a/jni/java/app/opendocument/core/HtmlConfig.java +++ b/jni/java/app/opendocument/core/HtmlConfig.java @@ -23,6 +23,9 @@ public final class HtmlConfig { public boolean textDocumentMargin = false; + /** The colors a document renders against. */ + public HtmlColorScheme colorScheme = HtmlColorScheme.LIGHT; + /** {@code null} disables the spreadsheet limit. */ public TableDimensions spreadsheetLimit = new TableDimensions(10000, 500); public boolean spreadsheetLimitByContent = true; diff --git a/jni/src/jni_style.cpp b/jni/src/jni_style.cpp index 84a6c6c89..cddad91c3 100644 --- a/jni/src/jni_style.cpp +++ b/jni/src/jni_style.cpp @@ -296,11 +296,12 @@ jobject make_file_type_capabilities(JNIEnv *env, const odr::FileTypeCapabilities &capabilities) { return new_object(env, "app/opendocument/core/FileTypeCapabilities", - "(ZZZZZZZ)V", + "(ZZZZZZZZ)V", static_cast(capabilities.detect_by_content), static_cast(capabilities.open), static_cast(capabilities.decrypt), static_cast(capabilities.translate_html), + static_cast(capabilities.color_scheme), static_cast(capabilities.edit), static_cast(capabilities.save), static_cast(capabilities.encrypt)); @@ -344,6 +345,9 @@ jobject html_config_to_java(JNIEnv *env, const odr::HtmlConfig &config) { set_boolean("relativeResourcePaths", config.relative_resource_paths); set_boolean("editable", config.editable); set_boolean("textDocumentMargin", config.text_document_margin); + set_object("colorScheme", "Lapp/opendocument/core/HtmlColorScheme;", + enum_from_code(env, "app/opendocument/core/HtmlColorScheme", + static_cast(config.color_scheme))); set_object("spreadsheetLimit", "Lapp/opendocument/core/TableDimensions;", config.spreadsheet_limit.has_value() ? make_table_dimensions(env, *config.spreadsheet_limit) @@ -452,6 +456,14 @@ odr::HtmlConfig html_config_from_java(JNIEnv *env, jobject config) { result.relative_resource_paths = get_boolean("relativeResourcePaths"); result.editable = get_boolean("editable"); result.text_document_margin = get_boolean("textDocumentMargin"); + { + const jint code = enum_ordinal( + env, + get_object("colorScheme", "Lapp/opendocument/core/HtmlColorScheme;")); + if (code >= 0) { + result.color_scheme = static_cast(code); + } + } { jobject limit = get_object("spreadsheetLimit", "Lapp/opendocument/core/TableDimensions;"); diff --git a/jni/tests/app/opendocument/core/HtmlTest.java b/jni/tests/app/opendocument/core/HtmlTest.java index b5b9bc86c..747f79f8c 100644 --- a/jni/tests/app/opendocument/core/HtmlTest.java +++ b/jni/tests/app/opendocument/core/HtmlTest.java @@ -80,6 +80,22 @@ void viewportModeReachesTheHtml() throws IOException { assertTrue(renderOdt(raw).contains("")); } + /** The C++ suite covers what the scheme paints; this only proves it crosses JNI. */ + @Test + void colorSchemeReachesTheHtml() throws IOException { + assertEquals(HtmlColorScheme.LIGHT, new HtmlConfig().colorScheme); + assertTrue(!renderOdt(new HtmlConfig()).contains("prefers-color-scheme")); + + HtmlConfig system = new HtmlConfig(); + system.colorScheme = HtmlColorScheme.SYSTEM; + assertTrue(renderOdt(system).contains("media=\"(prefers-color-scheme: dark)\"")); + + Path cache = Files.createDirectories(tempDir.resolve("scheme")); + DecodedFile file = Odr.open(TestFiles.odtFile(tempDir).toString()); + HtmlConfig readBack = Html.translate(file, cache.toString(), system).config(); + assertEquals(HtmlColorScheme.SYSTEM, readBack.colorScheme); + } + @Test void translateText() throws IOException { Html html = translateOffline(TestFiles.txtFile(tempDir)); diff --git a/jni/tests/app/opendocument/core/MetaTest.java b/jni/tests/app/opendocument/core/MetaTest.java index 1697b25e6..a54187c79 100644 --- a/jni/tests/app/opendocument/core/MetaTest.java +++ b/jni/tests/app/opendocument/core/MetaTest.java @@ -72,6 +72,7 @@ void capabilitiesByFileType() { FileTypeCapabilities odt = Odr.capabilitiesByFileType(FileType.OPENDOCUMENT_TEXT); assertTrue(odt.open); assertTrue(odt.translateHtml); + assertTrue(odt.colorScheme); assertTrue(odt.edit); // detected and named, but there is no decoder behind it @@ -82,6 +83,11 @@ void capabilitiesByFileType() { // spreadsheet editing is force-disabled assertFalse(Odr.capabilitiesByFileType(FileType.OPENDOCUMENT_SPREADSHEET).edit); + + // a pdf renders, but paints its own page backgrounds + FileTypeCapabilities pdf = Odr.capabilitiesByFileType(FileType.PORTABLE_DOCUMENT_FORMAT); + assertTrue(pdf.translateHtml); + assertFalse(pdf.colorScheme); } @Test diff --git a/python/src/bind_file.cpp b/python/src/bind_file.cpp index b0eaf79d6..ac48ef545 100644 --- a/python/src/bind_file.cpp +++ b/python/src/bind_file.cpp @@ -145,6 +145,7 @@ void odr_python::bind_file(py::module_ &m) { .def_readwrite("decrypt", &odr::FileTypeCapabilities::decrypt) .def_readwrite("translate_html", &odr::FileTypeCapabilities::translate_html) + .def_readwrite("color_scheme", &odr::FileTypeCapabilities::color_scheme) .def_readwrite("edit", &odr::FileTypeCapabilities::edit) .def_readwrite("save", &odr::FileTypeCapabilities::save) .def_readwrite("encrypt", &odr::FileTypeCapabilities::encrypt); diff --git a/python/src/bind_html.cpp b/python/src/bind_html.cpp index 375b838ce..956b1d177 100644 --- a/python/src/bind_html.cpp +++ b/python/src/bind_html.cpp @@ -30,6 +30,11 @@ void odr_python::bind_html(py::module_ &m) { .value("soft", odr::HtmlTableGridlines::soft) .value("hard", odr::HtmlTableGridlines::hard); + py::enum_(m, "HtmlColorScheme") + .value("light", odr::HtmlColorScheme::light) + .value("dark", odr::HtmlColorScheme::dark) + .value("system", odr::HtmlColorScheme::system); + py::enum_(m, "HtmlViewportMode") .value("automatic", odr::HtmlViewportMode::automatic) .value("fit_width", odr::HtmlViewportMode::fit_width) @@ -75,6 +80,7 @@ void odr_python::bind_html(py::module_ &m) { .def_readwrite("editable", &odr::HtmlConfig::editable) .def_readwrite("text_document_margin", &odr::HtmlConfig::text_document_margin) + .def_readwrite("color_scheme", &odr::HtmlConfig::color_scheme) .def_readwrite("spreadsheet_limit", &odr::HtmlConfig::spreadsheet_limit) .def_readwrite("spreadsheet_limit_by_content", &odr::HtmlConfig::spreadsheet_limit_by_content) diff --git a/python/tests/test_html.py b/python/tests/test_html.py index be5382c01..e55c14346 100644 --- a/python/tests/test_html.py +++ b/python/tests/test_html.py @@ -26,6 +26,14 @@ def test_html_config_defaults(): assert config.spreadsheet_limit.rows == 100 +def test_html_config_color_scheme_defaults(): + config = pyodr.HtmlConfig() + assert config.color_scheme == pyodr.HtmlColorScheme.light + + config.color_scheme = pyodr.HtmlColorScheme.system + assert config.color_scheme == pyodr.HtmlColorScheme.system + + def test_html_config_viewport_defaults(): config = pyodr.HtmlConfig() assert config.viewport_mode == pyodr.HtmlViewportMode.automatic diff --git a/python/tests/test_meta.py b/python/tests/test_meta.py index 01cde621f..8c0a71acf 100644 --- a/python/tests/test_meta.py +++ b/python/tests/test_meta.py @@ -96,6 +96,7 @@ def test_capabilities_by_file_type(): odt = pyodr.capabilities_by_file_type(pyodr.FileType.opendocument_text) assert odt.open assert odt.translate_html + assert odt.color_scheme assert odt.edit # detected and named, but there is no decoder behind it @@ -109,6 +110,11 @@ def test_capabilities_by_file_type(): pyodr.FileType.opendocument_spreadsheet ).edit + # a pdf renders, but paints its own page backgrounds + pdf = pyodr.capabilities_by_file_type(pyodr.FileType.portable_document_format) + assert pdf.translate_html + assert not pdf.color_scheme + def test_decoded_file_capabilities(odt_path): capabilities = pyodr.open(str(odt_path)).capabilities() diff --git a/src/odr/file.cpp b/src/odr/file.cpp index e64fc15e1..c91d2e488 100644 --- a/src/odr/file.cpp +++ b/src/odr/file.cpp @@ -172,6 +172,8 @@ FileTypeCapabilities DecodedFile::capabilities() const { // an encrypted file has to be decrypted before it renders result.translate_html = result.translate_html && encryption_state() != EncryptionState::encrypted; + // there is no scheme without html + result.color_scheme = result.color_scheme && result.translate_html; // `edit`/`save`/`encrypt` stay as declared — resolving them would mean // decoding the document; ask `Document` for the precise answer diff --git a/src/odr/file.hpp b/src/odr/file.hpp index 61ed44c74..45aea9375 100644 --- a/src/odr/file.hpp +++ b/src/odr/file.hpp @@ -182,6 +182,7 @@ struct FileTypeCapabilities final { bool open{}; ///< a decoder exists; @ref odr::open can decode it bool decrypt{}; ///< encrypted instances can be decrypted bool translate_html{}; ///< @ref html::translate produces output + bool color_scheme{}; ///< the view honors @ref HtmlConfig::color_scheme bool edit{}; ///< @ref Document::is_editable can be `true` bool save{}; ///< @ref Document::save is supported bool encrypt{}; ///< @ref Document::save with a password is supported diff --git a/src/odr/html.hpp b/src/odr/html.hpp index 4a4b06cf8..2f7cfa8ee 100644 --- a/src/odr/html.hpp +++ b/src/odr/html.hpp @@ -73,6 +73,14 @@ enum class HtmlTableGridlines { hard, }; +/// @brief The colors the emitted HTML renders against. @ref +/// FileTypeCapabilities::color_scheme says which views honor it. +enum class HtmlColorScheme { + light, ///< a white page, carrying the colors the document gives its content + dark, ///< a dark page, which the document's own colors give way to + system, ///< `light` or `dark`, by the reader's `prefers-color-scheme` +}; + /// @brief Initial zoom of the emitted HTML on mobile (viewport meta tag). /// Desktop browsers ignore the tag entirely. enum class HtmlViewportMode { @@ -124,6 +132,9 @@ struct HtmlConfig { // text document margin bool text_document_margin{false}; + // colors the output renders against + HtmlColorScheme color_scheme{HtmlColorScheme::light}; + // spreadsheet table limit std::optional spreadsheet_limit{TableDimensions(10000, 500)}; bool spreadsheet_limit_by_content{true}; diff --git a/src/odr/internal/file_type_table.cpp b/src/odr/internal/file_type_table.cpp index 093ecd948..61c6ecdd1 100644 --- a/src/odr/internal/file_type_table.cpp +++ b/src/odr/internal/file_type_table.cpp @@ -269,6 +269,7 @@ constexpr std::array table{ .open = true, .decrypt = true, .translate_html = true, + .color_scheme = true, .edit = true, .save = true}}, Row{FileType::opendocument_presentation, @@ -281,6 +282,7 @@ constexpr std::array table{ .open = true, .decrypt = true, .translate_html = true, + .color_scheme = true, .edit = true, .save = true}}, // ODF spreadsheet editing is force-disabled, see `odf::Document`. @@ -294,6 +296,7 @@ constexpr std::array table{ .open = true, .decrypt = true, .translate_html = true, + .color_scheme = true, .save = true}}, Row{FileType::opendocument_graphics, "odg"sv, @@ -305,6 +308,7 @@ constexpr std::array table{ .open = true, .decrypt = true, .translate_html = true, + .color_scheme = true, .edit = true, .save = true}}, @@ -318,6 +322,7 @@ constexpr std::array table{ .open = true, .decrypt = true, .translate_html = true, + .color_scheme = true, .edit = true, .save = true}}, Row{FileType::office_open_xml_presentation, @@ -329,7 +334,8 @@ constexpr std::array table{ {.detect_by_content = true, .open = true, .decrypt = true, - .translate_html = true}}, + .translate_html = true, + .color_scheme = true}}, Row{FileType::office_open_xml_workbook, "xlsx"sv, xlsx_extensions, @@ -339,7 +345,8 @@ constexpr std::array table{ {.detect_by_content = true, .open = true, .decrypt = true, - .translate_html = true}}, + .translate_html = true, + .color_scheme = true}}, Row{FileType::office_open_xml_encrypted, "ooxml_encrypted"sv, {}, @@ -362,21 +369,30 @@ constexpr std::array table{ doc_mimetypes, FileCategory::document, DocumentType::text, - {.detect_by_content = true, .open = true, .translate_html = true}}, + {.detect_by_content = true, + .open = true, + .translate_html = true, + .color_scheme = true}}, Row{FileType::legacy_powerpoint_presentation, "ppt"sv, ppt_extensions, ppt_mimetypes, FileCategory::document, DocumentType::presentation, - {.detect_by_content = true, .open = true, .translate_html = true}}, + {.detect_by_content = true, + .open = true, + .translate_html = true, + .color_scheme = true}}, Row{FileType::legacy_excel_worksheets, "xls"sv, xls_extensions, xls_mimetypes, FileCategory::document, DocumentType::spreadsheet, - {.detect_by_content = true, .open = true, .translate_html = true}}, + {.detect_by_content = true, + .open = true, + .translate_html = true, + .color_scheme = true}}, // Recognised by magic so a caller can name the type, but there is no // decoder behind either of these. @@ -412,21 +428,30 @@ constexpr std::array table{ txt_mimetypes, FileCategory::text, DocumentType::unknown, - {.detect_by_content = true, .open = true, .translate_html = true}}, + {.detect_by_content = true, + .open = true, + .translate_html = true, + .color_scheme = true}}, Row{FileType::comma_separated_values, "csv"sv, csv_extensions, csv_mimetypes, FileCategory::text, DocumentType::spreadsheet, - {.detect_by_content = true, .open = true, .translate_html = true}}, + {.detect_by_content = true, + .open = true, + .translate_html = true, + .color_scheme = true}}, Row{FileType::javascript_object_notation, "json"sv, json_extensions, json_mimetypes, FileCategory::text, DocumentType::unknown, - {.detect_by_content = true, .open = true, .translate_html = true}}, + {.detect_by_content = true, + .open = true, + .translate_html = true, + .color_scheme = true}}, // Classified for callers that route files by type; there is no decoder. Row{FileType::markdown, "md"sv, @@ -442,14 +467,20 @@ constexpr std::array table{ zip_mimetypes, FileCategory::archive, DocumentType::unknown, - {.detect_by_content = true, .open = true, .translate_html = true}}, + {.detect_by_content = true, + .open = true, + .translate_html = true, + .color_scheme = true}}, Row{FileType::compound_file_binary_format, "cfb"sv, cfb_extensions, cfb_mimetypes, FileCategory::archive, DocumentType::unknown, - {.detect_by_content = true, .open = true, .translate_html = true}}, + {.detect_by_content = true, + .open = true, + .translate_html = true, + .color_scheme = true}}, Row{FileType::portable_network_graphics, "png"sv, @@ -457,35 +488,50 @@ constexpr std::array table{ png_mimetypes, FileCategory::image, DocumentType::unknown, - {.detect_by_content = true, .open = true, .translate_html = true}}, + {.detect_by_content = true, + .open = true, + .translate_html = true, + .color_scheme = true}}, Row{FileType::graphics_interchange_format, "gif"sv, gif_extensions, gif_mimetypes, FileCategory::image, DocumentType::unknown, - {.detect_by_content = true, .open = true, .translate_html = true}}, + {.detect_by_content = true, + .open = true, + .translate_html = true, + .color_scheme = true}}, Row{FileType::jpeg, "jpg"sv, jpg_extensions, jpg_mimetypes, FileCategory::image, DocumentType::unknown, - {.detect_by_content = true, .open = true, .translate_html = true}}, + {.detect_by_content = true, + .open = true, + .translate_html = true, + .color_scheme = true}}, Row{FileType::bitmap_image_file, "bmp"sv, bmp_extensions, bmp_mimetypes, FileCategory::image, DocumentType::unknown, - {.detect_by_content = true, .open = true, .translate_html = true}}, + {.detect_by_content = true, + .open = true, + .translate_html = true, + .color_scheme = true}}, Row{FileType::starview_metafile, "svm"sv, svm_extensions, svm_mimetypes, FileCategory::image, DocumentType::unknown, - {.detect_by_content = true, .open = true, .translate_html = true}}, + {.detect_by_content = true, + .open = true, + .translate_html = true, + .color_scheme = true}}, Row{FileType::truetype_font, "ttf"sv, @@ -493,14 +539,20 @@ constexpr std::array table{ ttf_mimetypes, FileCategory::font, DocumentType::unknown, - {.detect_by_content = true, .open = true, .translate_html = true}}, + {.detect_by_content = true, + .open = true, + .translate_html = true, + .color_scheme = true}}, Row{FileType::opentype_font, "otf"sv, otf_extensions, otf_mimetypes, FileCategory::font, DocumentType::unknown, - {.detect_by_content = true, .open = true, .translate_html = true}}, + {.detect_by_content = true, + .open = true, + .translate_html = true, + .color_scheme = true}}, // Named but not decoded: `open` wraps the bytes and `translate_html` hands // them to the browser in an `` or a player. Nothing reads a pixel or @@ -511,28 +563,40 @@ constexpr std::array table{ webp_mimetypes, FileCategory::image, DocumentType::unknown, - {.detect_by_content = true, .open = true, .translate_html = true}}, + {.detect_by_content = true, + .open = true, + .translate_html = true, + .color_scheme = true}}, Row{FileType::tagged_image_file_format, "tiff"sv, tiff_extensions, tiff_mimetypes, FileCategory::image, DocumentType::unknown, - {.detect_by_content = true, .open = true, .translate_html = true}}, + {.detect_by_content = true, + .open = true, + .translate_html = true, + .color_scheme = true}}, Row{FileType::high_efficiency_image_format, "heif"sv, heif_extensions, heif_mimetypes, FileCategory::image, DocumentType::unknown, - {.detect_by_content = true, .open = true, .translate_html = true}}, + {.detect_by_content = true, + .open = true, + .translate_html = true, + .color_scheme = true}}, Row{FileType::av1_image_file_format, "avif"sv, avif_extensions, avif_mimetypes, FileCategory::image, DocumentType::unknown, - {.detect_by_content = true, .open = true, .translate_html = true}}, + {.detect_by_content = true, + .open = true, + .translate_html = true, + .color_scheme = true}}, Row{FileType::mpeg_audio, "mp3"sv, @@ -615,49 +679,70 @@ constexpr std::array table{ svg_mimetypes, FileCategory::image, DocumentType::unknown, - {.detect_by_content = true, .open = true, .translate_html = true}}, + {.detect_by_content = true, + .open = true, + .translate_html = true, + .color_scheme = true}}, Row{FileType::windows_icon, "ico"sv, ico_extensions, ico_mimetypes, FileCategory::image, DocumentType::unknown, - {.detect_by_content = true, .open = true, .translate_html = true}}, + {.detect_by_content = true, + .open = true, + .translate_html = true, + .color_scheme = true}}, Row{FileType::jpeg_xl, "jxl"sv, jxl_extensions, jxl_mimetypes, FileCategory::image, DocumentType::unknown, - {.detect_by_content = true, .open = true, .translate_html = true}}, + {.detect_by_content = true, + .open = true, + .translate_html = true, + .color_scheme = true}}, Row{FileType::jpeg_2000, "jp2"sv, jp2_extensions, jp2_mimetypes, FileCategory::image, DocumentType::unknown, - {.detect_by_content = true, .open = true, .translate_html = true}}, + {.detect_by_content = true, + .open = true, + .translate_html = true, + .color_scheme = true}}, Row{FileType::photoshop_document, "psd"sv, psd_extensions, psd_mimetypes, FileCategory::image, DocumentType::unknown, - {.detect_by_content = true, .open = true, .translate_html = true}}, + {.detect_by_content = true, + .open = true, + .translate_html = true, + .color_scheme = true}}, Row{FileType::windows_metafile, "wmf"sv, wmf_extensions, wmf_mimetypes, FileCategory::image, DocumentType::unknown, - {.detect_by_content = true, .open = true, .translate_html = true}}, + {.detect_by_content = true, + .open = true, + .translate_html = true, + .color_scheme = true}}, Row{FileType::enhanced_metafile, "emf"sv, emf_extensions, emf_mimetypes, FileCategory::image, DocumentType::unknown, - {.detect_by_content = true, .open = true, .translate_html = true}}, + {.detect_by_content = true, + .open = true, + .translate_html = true, + .color_scheme = true}}, // Not decoded: it renders as a source view of itself. Row{FileType::xml, @@ -666,7 +751,10 @@ constexpr std::array table{ xml_mimetypes, FileCategory::text, DocumentType::unknown, - {.detect_by_content = true, .open = true, .translate_html = true}}, + {.detect_by_content = true, + .open = true, + .translate_html = true, + .color_scheme = true}}, }; /// Finds the row whose list, selected by @p list, contains @p needle. diff --git a/src/odr/internal/html/document.cpp b/src/odr/internal/html/document.cpp index 06d0d5d71..a73530af0 100644 --- a/src/odr/internal/html/document.cpp +++ b/src/odr/internal/html/document.cpp @@ -54,9 +54,12 @@ void front(const Document &document, const WritingState &state, : std::nullopt); write_document_style(state); + write_document_dark_style(state); write_search_style(state); + write_search_dark_style(state); if (document.document_type() == DocumentType::spreadsheet) { write_spreadsheet_style(state); + write_spreadsheet_dark_style(state); } out.write_header_end(); @@ -317,7 +320,8 @@ class TextHtmlFragment final : public HtmlFragmentBase { out.write_element_end("div"); out.write_element_end("div"); } else { - out.write_element_begin("div"); + out.write_element_begin("div", + HtmlElementOptions().set_class("odr-text-flow")); translate_children(element.children(), state); out.write_element_end("div"); } diff --git a/src/odr/internal/html/filesystem.cpp b/src/odr/internal/html/filesystem.cpp index 5cfe12370..ad8b14c85 100644 --- a/src/odr/internal/html/filesystem.cpp +++ b/src/odr/internal/html/filesystem.cpp @@ -189,7 +189,9 @@ class HtmlServiceImpl final : public HtmlService { out.write_header_title("odr"); write_viewport_meta(out, config(), false); write_filesystem_style(state); + write_filesystem_dark_style(state); write_search_style(state); + write_search_dark_style(state); out.write_header_end(); out.write_body_begin(); diff --git a/src/odr/internal/html/font_file.cpp b/src/odr/internal/html/font_file.cpp index ffb6baa3f..b1555e455 100644 --- a/src/odr/internal/html/font_file.cpp +++ b/src/odr/internal/html/font_file.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -122,6 +123,15 @@ class HtmlServiceImpl final : public HtmlService { out.out() << ".gid{font-size:.6em;color:#888;}"; out.out() << ""; + if (writes_dark_style(config())) { + out.write_header_style_begin(dark_style_media(config())); + out.out() << ":root{color-scheme:dark}"; + out.out() << "body{background:#0d1117;color:#e6edf3;}"; + out.out() << ".cell{border-color:#30363d;}"; + out.out() << ".gid{color:#8b949e;}"; + out.write_header_style_end(); + } + out.out() << "

" << escape_text(font->name()) << "

"; out.out() << "

glyphs: " << font->glyph_count() << " · units/em: " << font->units_per_em() diff --git a/src/odr/internal/html/frontend.cpp b/src/odr/internal/html/frontend.cpp index 0f3063b02..733406993 100644 --- a/src/odr/internal/html/frontend.cpp +++ b/src/odr/internal/html/frontend.cpp @@ -35,12 +35,33 @@ x-s{display:inline} Not a negative `z-index`, which is one too but takes the page out of reach of hit testing. */ .odr-page-outer{display:flex;margin:0 16px;background:#fff;box-shadow:0 1px 4px rgba(0,0,0,.5);isolation:isolate} +/* Reflowed to the viewport there is no page box to inset the text. A physical + measure, like the page margin it stands in for. */ +.odr-text-flow{padding:3mm} /* The label is text rather than a `::marker`, which no selection would copy. It hangs into the item's padding so wrapped lines align under the text. */ .odr-list-item{padding-left:2em} .odr-list-marker{display:inline-block;min-width:2em;margin-left:-2em;white-space:pre} )css"; +/// Dark counterpart of the style above. The `media` attribute of the element +/// carrying it gates it — a rule inside a query cannot beat an inline color. +constexpr std::string_view document_dark_css = R"css( +:root{color-scheme:dark} +body{background:#0d1117;color:#e6edf3} +/* The step to the page is what makes it read as a sheet. */ +.odr-background{background:#010409} +.odr-page-outer{background-color:#161b22!important;box-shadow:0 1px 4px rgba(0,0,0,.8)} +/* Fills give way to the page, text to one legible against it. + `background-image` is left alone: a page printed on a picture keeps it. */ +div,table,tr,td,x-p,x-s{background-color:transparent!important} +/* A shape's fill is an svg `fill` (`translate_drawing_style`), not a + background, and gives way like one. The stroke stays: it is the line. */ +svg,svg *{fill:transparent!important} +td,x-p,x-s{color:#e6edf3!important} +a,a x-p,a x-s{color:#6cb6ff!important} +)css"; + /// No `text-overflow`: it sat on `td`, whose overflow is visible, and making it /// work would mean clipping. constexpr std::string_view spreadsheet_css = R"css( @@ -88,6 +109,21 @@ body{margin:0;background:var(--odr-sheet-canvas)} .odr-sheet-sort-asc::after{content:"\25B4"} )css"; +constexpr std::string_view spreadsheet_dark_css = R"css( +:root{ +--odr-sheet-line:#30363d; +--odr-sheet-rule:#484f58; +--odr-sheet-ruler:#161b22; +--odr-sheet-ruler-text:#8b949e; +--odr-sheet-canvas:#0d1117; +--odr-sheet-wash:rgba(255,255,255,.05); +--odr-sheet-wash-pinned:rgba(255,255,255,.10); +--odr-sheet-wash-ruler:rgba(255,255,255,.12); +--odr-sheet-focus:#4c8dff; +} +.odr-sheet{background-color:#161b22!important} +)css"; + /// A whole-pixel line height, because the line numbers are a second column /// whose cells are sized to the lines by script - a fractional line would round /// per cell and the two columns would drift apart. @@ -115,6 +151,19 @@ body{margin:0;background:#fff} [contenteditable]:focus{outline:none} )css"; +/// The gutter steps the other way in dark: lighter than the ground. +constexpr std::string_view text_dark_css = R"css( +:root{ +color-scheme:dark; +--odr-text-fg:#e6edf3; +--odr-text-muted:#8b949e; +--odr-text-line:#30363d; +--odr-text-gutter:#161b22; +--odr-text-wash:rgba(255,255,255,.05); +} +body{background:#0d1117} +)css"; + /// No numbered gutter - the numbers would be ours, not the file's. The column /// carries the fold handles, and every line reserves it. constexpr std::string_view xml_css = R"css( @@ -150,6 +199,21 @@ body{margin:0;background:#fff} .odr-xml-decl,.odr-xml-doctype,.odr-xml-pi{color:var(--odr-xml-meta)} )css"; +constexpr std::string_view xml_dark_css = R"css( +:root{ +color-scheme:dark; +--odr-xml-text:#e6edf3; +--odr-xml-muted:#8b949e; +--odr-xml-punct:#9198a1; +--odr-xml-name:#7ee787; +--odr-xml-attr:#ffa657; +--odr-xml-value:#a5d6ff; +--odr-xml-meta:#d2a8ff; +} +body{background:#0d1117} +.odr-xml summary:hover{background:rgba(255,255,255,.06)} +)css"; + constexpr std::string_view filesystem_css = R"css( :root{ --odr-files-line:#e3e5e8; @@ -172,6 +236,19 @@ body{margin:0;background:#fff;color:#1f2328;font:13px/1.5 var(--odr-files-font)} .odr-files-action a:hover{background:rgba(0,0,0,.07);color:var(--odr-files-link)} )css"; +/// The washes lighten rather than darken: a black one paints nothing here. +constexpr std::string_view filesystem_dark_css = R"css( +:root{ +color-scheme:dark; +--odr-files-line:#30363d; +--odr-files-muted:#8b949e; +--odr-files-link:#6cb6ff; +} +body{background:#0d1117;color:#e6edf3} +.odr-files tbody tr:hover>*{background-image:linear-gradient(rgba(255,255,255,.05),rgba(255,255,255,.05))} +.odr-files-action a:hover{background:rgba(255,255,255,.08)} +)css"; + constexpr std::string_view media_css = R"css( body{margin:0;background:#000} .odr-media{display:flex;align-items:center;justify-content:center;min-height:100vh} @@ -185,6 +262,11 @@ mark{background:#ff0} mark.current{background:orange} )css"; +/// The mark keeps its yellow; only the text on it turns over. +constexpr std::string_view search_dark_css = R"css( +mark{color:#0d1117!important} +)css"; + constexpr std::string_view document_js = R"js( (function () { "use strict"; @@ -1100,18 +1182,32 @@ struct Asset { constexpr Asset document_css_asset{HtmlResourceType::css, "text/css", "document.css", document_css}; +constexpr Asset document_dark_css_asset{HtmlResourceType::css, "text/css", + "document-dark.css", document_dark_css}; constexpr Asset spreadsheet_css_asset{HtmlResourceType::css, "text/css", "spreadsheet.css", spreadsheet_css}; +constexpr Asset spreadsheet_dark_css_asset{HtmlResourceType::css, "text/css", + "spreadsheet-dark.css", + spreadsheet_dark_css}; constexpr Asset text_css_asset{HtmlResourceType::css, "text/css", "text.css", text_css}; +constexpr Asset text_dark_css_asset{HtmlResourceType::css, "text/css", + "text-dark.css", text_dark_css}; constexpr Asset xml_css_asset{HtmlResourceType::css, "text/css", "xml.css", xml_css}; +constexpr Asset xml_dark_css_asset{HtmlResourceType::css, "text/css", + "xml-dark.css", xml_dark_css}; constexpr Asset filesystem_css_asset{HtmlResourceType::css, "text/css", "filesystem.css", filesystem_css}; +constexpr Asset filesystem_dark_css_asset{HtmlResourceType::css, "text/css", + "filesystem-dark.css", + filesystem_dark_css}; constexpr Asset media_css_asset{HtmlResourceType::css, "text/css", "media.css", media_css}; constexpr Asset search_css_asset{HtmlResourceType::css, "text/css", "search.css", search_css}; +constexpr Asset search_dark_css_asset{HtmlResourceType::css, "text/css", + "search-dark.css", search_dark_css}; constexpr Asset document_js_asset{HtmlResourceType::js, "text/javascript", "document.js", document_js}; constexpr Asset search_js_asset{HtmlResourceType::js, "text/javascript", @@ -1142,19 +1238,40 @@ HtmlResources locate_all(const std::span assets, return resources; } -void write_style(const Asset &asset, const WritingState &state) { +/// Adds the dark sheets where the config asks for them. +HtmlResources locate_all(const std::span assets, + const std::span dark, + const HtmlConfig &config) { + HtmlResources resources = locate_all(assets, config); + if (writes_dark_style(config)) { + for (const Asset &asset : dark) { + locate(asset, config, resources); + } + } + return resources; +} + +/// @p media, when given, gates the stylesheet on that media query. +void write_style(const Asset &asset, const WritingState &state, + const std::string_view media = {}) { if (const HtmlResourceLocation location = locate(asset, state.config(), state.resources()); location.has_value()) { - state.out().write_header_style(escape_attribute(*location)); + state.out().write_header_style(escape_attribute(*location), media); return; } - state.out().write_header_style_begin(); + state.out().write_header_style_begin(media); state.out().out() << asset.content; state.out().write_header_style_end(); } +void write_dark_style(const Asset &asset, const WritingState &state) { + if (writes_dark_style(state.config())) { + write_style(asset, state, dark_style_media(state.config())); + } +} + void write_script(const Asset &asset, const WritingState &state) { if (const HtmlResourceLocation location = locate(asset, state.config(), state.resources()); @@ -1174,6 +1291,16 @@ void write_script(const Asset &asset, const WritingState &state) { namespace odr::internal { +bool html::writes_dark_style(const HtmlConfig &config) { + return config.color_scheme != HtmlColorScheme::light; +} + +std::string_view html::dark_style_media(const HtmlConfig &config) { + return config.color_scheme == HtmlColorScheme::system + ? "(prefers-color-scheme: dark)" + : ""; +} + void html::write_document_style(const WritingState &state) { write_style(document_css_asset, state); } @@ -1182,18 +1309,38 @@ void html::write_spreadsheet_style(const WritingState &state) { write_style(spreadsheet_css_asset, state); } +void html::write_document_dark_style(const WritingState &state) { + write_dark_style(document_dark_css_asset, state); +} + +void html::write_spreadsheet_dark_style(const WritingState &state) { + write_dark_style(spreadsheet_dark_css_asset, state); +} + void html::write_text_style(const WritingState &state) { write_style(text_css_asset, state); } +void html::write_text_dark_style(const WritingState &state) { + write_dark_style(text_dark_css_asset, state); +} + void html::write_xml_style(const WritingState &state) { write_style(xml_css_asset, state); } +void html::write_xml_dark_style(const WritingState &state) { + write_dark_style(xml_dark_css_asset, state); +} + void html::write_filesystem_style(const WritingState &state) { write_style(filesystem_css_asset, state); } +void html::write_filesystem_dark_style(const WritingState &state) { + write_dark_style(filesystem_dark_css_asset, state); +} + void html::write_media_style(const WritingState &state) { write_style(media_css_asset, state); } @@ -1202,6 +1349,10 @@ void html::write_search_style(const WritingState &state) { write_style(search_css_asset, state); } +void html::write_search_dark_style(const WritingState &state) { + write_dark_style(search_dark_css_asset, state); +} + void html::write_document_script(const WritingState &state) { write_script(document_js_asset, state); } @@ -1221,13 +1372,15 @@ void html::write_text_script(const WritingState &state) { HtmlResources html::locate_text_resources(const HtmlConfig &config) { static constexpr std::array assets{text_css_asset, search_css_asset, search_js_asset, text_js_asset}; - return locate_all(assets, config); + static constexpr std::array dark{text_dark_css_asset, search_dark_css_asset}; + return locate_all(assets, dark, config); } HtmlResources html::locate_xml_resources(const HtmlConfig &config) { static constexpr std::array assets{xml_css_asset, search_css_asset, search_js_asset}; - return locate_all(assets, config); + static constexpr std::array dark{xml_dark_css_asset, search_dark_css_asset}; + return locate_all(assets, dark, config); } HtmlResources html::locate_search_resources(const HtmlConfig &config) { diff --git a/src/odr/internal/html/frontend.hpp b/src/odr/internal/html/frontend.hpp index 5d2c8ba99..f0047335f 100644 --- a/src/odr/internal/html/frontend.hpp +++ b/src/odr/internal/html/frontend.hpp @@ -2,6 +2,8 @@ #include +#include + namespace odr::internal::html { struct WritingState; @@ -9,15 +11,30 @@ struct WritingState; /// Each of these writes one complete `