diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 6596a41a..a53afc8a 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -122,8 +122,8 @@ jobs: - name: setup gradle uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6 - # every ABI's artifact carries the same assets/, so they merge into the - # one prebuilt tree the gradle build reads + # each ABI's artifact carries its own jniLibs/, so they merge into + # the one prebuilt tree the gradle build reads - name: download native libraries uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: diff --git a/.github/workflows/apple.yml b/.github/workflows/apple.yml index 37768edb..fc828aa2 100644 --- a/.github/workflows/apple.yml +++ b/.github/workflows/apple.yml @@ -200,8 +200,8 @@ jobs: # The iOS *device* slice is only ever link checked; nothing runs it. The # simulator run is the analogue of android's instrumented job and the only one - # that sees what a device sees — that `+load` fired, that `NSBundle` found the - # renderer assets, that a temp directory is writable inside an app container. + # that sees what a device sees — that the framework loads, that rendering + # works unconfigured, that a temp directory is writable in an app container. test: needs: xcframework runs-on: macos-15 diff --git a/.github/workflows/build_test.yml b/.github/workflows/build_test.yml index 69abf0b5..1eac5f0c 100644 --- a/.github/workflows/build_test.yml +++ b/.github/workflows/build_test.yml @@ -125,7 +125,6 @@ jobs: -DCMAKE_CXX_FLAGS="-Werror" -DCMAKE_INSTALL_PREFIX=install -DODR_TEST=ON - -DODR_BUNDLE_ASSETS=ON ${{ matrix.bindings && '-DODR_JNI=ON -DODR_PYTHON=ON' || '' }} - name: cmake @@ -170,7 +169,6 @@ jobs: install build/test/odr_test build/test/Release/odr_test.exe - build/data if-no-files-found: error include-hidden-files: true diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index b7333065..d3aee263 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -109,13 +109,11 @@ jobs: restore-keys: | ccache-${{ env.CACHE_FLAVOR }}-${{ matrix.host_profile }}-${{ env.CCACHE_KEY_SUFFIX }}- - # Match the dependency set that pyproject.toml enables. `bundle_assets` - # is what stages the renderer's css/js so they land in `pyodr/data`. + # Match the dependency set that pyproject.toml enables. - name: conan install run: > conan install . -o '&:with_python=True' - -o '&:bundle_assets=True' --profile:host '${{ matrix.host_profile }}' --profile:build '${{ matrix.build_profile }}' --build missing diff --git a/CMakeLists.txt b/CMakeLists.txt index 73785b45..e0abb814 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -21,7 +21,7 @@ option(ODR_CLANG_TIDY "Run clang-tidy static analysis" OFF) # containers, which libmagic cannot, and it costs neither a dependency nor the # 8 MB `magic.mgc` database every consumer had to ship and point us at. option(ODR_WITH_LIBMAGIC "Removed, does nothing (deprecated)" OFF) -option(ODR_BUNDLE_ASSETS "Bundle assets during build and install" OFF) +option(ODR_BUNDLE_ASSETS "Removed, does nothing (deprecated)" OFF) option(ODR_PYTHON "Build Python bindings" OFF) option(ODR_JNI "Build JNI bindings" OFF) option(ODR_APPLE "Build Objective-C bindings as a framework" OFF) @@ -61,13 +61,6 @@ find_package(uchardet REQUIRED) find_package(utf8cpp REQUIRED) find_package(argon2 REQUIRED) -include(FetchContent) -FetchContent_Declare(odr.js - URL "https://github.com/opendocument-app/OpenDocument.js/releases/download/v2.0.0/build.zip" - URL_HASH SHA256=5ccbe1c5bb46d65bc0dccc6c6df6548a571eef5536fcb95f9dcb80add26800b6 -) -FetchContent_MakeAvailable(odr.js) - set(PRE_CONFIGURE_FILE "src/odr/internal/git_info.cpp.in") set(POST_CONFIGURE_FILE "${CMAKE_CURRENT_BINARY_DIR}/src/odr/internal/git_info.cpp") # An explicitly injected `GIT_HEAD_SHA1` wins over the working tree, which is @@ -141,6 +134,7 @@ set(ODR_SOURCE_FILES "src/odr/internal/html/document_element.cpp" "src/odr/internal/html/filesystem.cpp" "src/odr/internal/html/font_file.cpp" + "src/odr/internal/html/frontend.cpp" "src/odr/internal/html/html_service.cpp" "src/odr/internal/html/html_writer.cpp" "src/odr/internal/html/image_file.cpp" @@ -274,10 +268,6 @@ target_link_libraries(odr argon2::argon2 ) -set(ODR_BUILD_ODR_DATA_PATH "${CMAKE_CURRENT_BINARY_DIR}/data") -file(COPY "${odr.js_SOURCE_DIR}/" DESTINATION "${ODR_BUILD_ODR_DATA_PATH}") -set(ODR_INSTALL_ODR_DATA_PATH "${CMAKE_INSTALL_DATADIR}") - if (ODR_WITH_HTTP_SERVER) find_package(httplib REQUIRED) target_sources(odr @@ -299,6 +289,12 @@ if (ODR_WITH_LIBMAGIC) "`odr::mimetype` is our own detection. Drop the option; " "`GlobalParams::libmagic_database_path` is inert too.") endif () +if (ODR_BUNDLE_ASSETS) + message(DEPRECATION + "ODR_BUNDLE_ASSETS no longer does anything: the css and js are " + "part of the library and there is nothing left to ship. Drop the " + "option; `GlobalParams::odr_core_data_path` is inert too.") +endif () configure_file("src/odr/internal/project_info.cpp.in" "src/odr/internal/project_info.cpp") @@ -355,7 +351,3 @@ install( LIBRARY DESTINATION "${CMAKE_INSTALL_LIBDIR}" ARCHIVE DESTINATION "${CMAKE_INSTALL_LIBDIR}" ) -install( - DIRECTORY "${ODR_BUILD_ODR_DATA_PATH}/" - DESTINATION "${ODR_INSTALL_ODR_DATA_PATH}" -) diff --git a/android/AGENTS.md b/android/AGENTS.md index cfbf00b6..b622ffab 100644 --- a/android/AGENTS.md +++ b/android/AGENTS.md @@ -9,9 +9,9 @@ first — the java API and its android constraints live there. User facing docs: | Path | What | |------|------| -| `build.gradle.kts` | The library module: sources from `../jni/java`, prebuilt native libs and assets, lint, publishing. Single project — `rootProject.name` *is* the artifactId. | -| `build_native.py` | conan + cmake per ABI → `native/prebuilt/{jniLibs,assets}`. Invoked by the `buildNative` gradle task and directly by CI. | -| `src/main/java/.../android/OdrAndroid.kt` | The only android specific production code: extracts the bundled assets and registers them with `GlobalParams`. | +| `build.gradle.kts` | The library module: sources from `../jni/java`, prebuilt native libs, lint, publishing. Single project — `rootProject.name` *is* the artifactId. | +| `build_native.py` | conan + cmake per ABI → `native/prebuilt/jniLibs`. Invoked by the `buildNative` gradle task and directly by CI. | +| `src/main/java/.../android/OdrAndroid.kt` | The only android specific production code, and a deprecated no-op: it used to extract the renderer's assets, which are part of the library now. | | `src/androidTest/` | Instrumented suite, JUnit 4 + androidx.test, inputs from `../jni/testfixtures`. | | `consumer-rules.pro` | Keeps `app.opendocument.core.**` — JNI resolves it by name, R8 cannot see that. | diff --git a/android/README.md b/android/README.md index 001de352..c5a49c70 100644 --- a/android/README.md +++ b/android/README.md @@ -8,7 +8,6 @@ odr-core-android.aar ├── classes.jar app.opendocument.core (../jni/java) + OdrAndroid ├── jni//libodr_jni.so the bindings with the core linked in ├── jni//libc++_shared.so the c++ runtime they were built against -├── assets/core/odrcore/* css/js of the html renderer └── proguard.txt keeps the classes JNI resolves by name ``` @@ -33,22 +32,16 @@ Packages needs a token with `read:packages` even for public packages — see ```kotlin import app.opendocument.core.* -import app.opendocument.core.android.OdrAndroid - -OdrAndroid.init(context) // extracts the bundled assets, loads the library val file = Odr.open(path) val service = Html.translate(file, cacheDir.path, HtmlConfig()) val html = service.bringOffline(outputDir.path) ``` -The java API is java and stays callable as such (`OdrAndroid.init(context)` is -a static method, and it still throws a checked `IOException`); only this -module's own code — `OdrAndroid` and the instrumented suite — is kotlin. - -`OdrAndroid.init` is idempotent and cheap after the first call: the assets are -unpacked once per library build, into the app's no-backup storage, and the -library is pointed at them via `GlobalParams`. +Nothing needs initialising: the renderer's css and js are part of the native +library. `OdrAndroid.init(context)` is a deprecated no-op — it used to unpack +the assets that carried them — and stays callable, checked `IOException` and +all, so apps written against the older AAR keep compiling. Serving the rendered HTML through `HttpServer` needs two things from the app, neither of which a library may decide on its own: `android.permission.INTERNET`, @@ -93,8 +86,7 @@ The odrcore build is a normal one — `ODR_JNI=ON`, static core linked into ``` The instrumented suite (`src/androidTest`) is the part that sees what a device -sees: it loads the native library, extracts and reads the bundled assets, -decodes and renders documents, drives a java log sink from native code, and +sees: it loads the native library, decodes and renders documents, drives a java log sink from native code, and serves a document over HTTP. Its inputs come from `../jni/testfixtures`, the same ones the host junit suite uses. @@ -129,8 +121,7 @@ that click is the last point at which a bad artifact can be dropped rather than lived with. For now OpenDocument.droid keeps building odrcore from the conan package -(`with_jni=True`), deploying `libodr_jni.so`, `odr-core-java.jar` and the assets -out of it. That path is unaffected by anything here, and it is the reason the +(`with_jni=True`), deploying `libodr_jni.so` and `odr-core-java.jar` out of it. That path is unaffected by anything here, and it is the reason the two halves cannot drift: they come out of one build. So the AAR is, for now, a second packaging of that same build — for consumers diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 24c8f290..8ebc590d 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -68,19 +68,17 @@ val buildNative = enabled = nativeAbis.isNotEmpty() } -// An AAR without the native library or without its assets builds and publishes -// happily and then fails at runtime in whatever app picked it up, so make the -// absence a build error. +// An AAR without the native library builds and publishes happily and then +// fails at runtime in whatever app picked it up, so make the absence a build +// error. val checkNative = tasks.register("checkNative") { dependsOn(buildNative) - val prebuilt = layout.projectDirectory.dir("native/prebuilt") - val required = listOf("jniLibs", "assets").map { prebuilt.dir(it).asFile } + val jniLibs = layout.projectDirectory.dir("native/prebuilt/jniLibs").asFile doLast { - val missing = required.filter { it.list().isNullOrEmpty() } - if (missing.isNotEmpty()) { + if (jniLibs.list().isNullOrEmpty()) { throw GradleException( - "nothing in ${missing.joinToString()} — run android/build_native.py, " + + "nothing in $jniLibs — run android/build_native.py, " + "or pass -Podr.abis= to have the build run it" ) } @@ -104,10 +102,9 @@ android { targetCompatibility = JavaVersion.VERSION_17 } - // the instrumented apk carries the bindings and the renderer assets, and - // pushing that onto a cold emulator outlasts ddmlib's default timeout, which - // surfaces as a ShellCommandUnresponsiveException rather than as a failing - // test + // pushing the instrumented apk onto a cold emulator outlasts ddmlib's + // default timeout, which surfaces as a ShellCommandUnresponsiveException + // rather than as a failing test installation { timeOutInMs = 10 * 60 * 1000 } sourceSets { @@ -116,7 +113,6 @@ android { // artifacts cannot drift apart java.srcDir("../jni/java") jniLibs.srcDir("native/prebuilt/jniLibs") - assets.srcDir("native/prebuilt/assets") } named("androidTest") { // the host junit suite's inputs, shared verbatim diff --git a/android/build_native.py b/android/build_native.py index 22e34ebe..fc6e4bdb 100644 --- a/android/build_native.py +++ b/android/build_native.py @@ -1,21 +1,19 @@ #!/usr/bin/env python3 -"""Build the native half of the AAR: `libodr_jni.so` per android ABI, plus the -runtime assets, laid out the way `build.gradle.kts` expects. +"""Build the native half of the AAR: `libodr_jni.so` per android ABI, laid out +the way `build.gradle.kts` expects. android/build_native.py --abi x86_64 --abi armv8 Each ABI gets its own conan install (`android-` host profile) and cmake build under `android/native/build/`, and the results are copied into `--output` (default `android/native/prebuilt`), which the gradle build reads as -its jniLibs and assets source sets: +its jniLibs source set: prebuilt/jniLibs//libodr_jni.so the bindings, core linked in prebuilt/jniLibs//libc++_shared.so from the NDK, see below - prebuilt/assets/core/odrcore/* css/js of the html renderer `libc++_shared.so` has to be shipped because the android profiles build against -the shared c++ runtime and nothing else in a consuming app pulls it in; the -asset layout mirrors what OpenDocument.droid's conan deployer already produces. +the shared c++ runtime and nothing else in a consuming app pulls it in. """ import argparse @@ -98,8 +96,7 @@ def build(architecture: str, conan: str, build_profile: str, output: Path) -> No "-DODR_JNI=ON", "-DODR_CLI=OFF", "-DODR_TEST=OFF", - "-DODR_WITH_HTTP_SERVER=ON", - "-DODR_BUNDLE_ASSETS=ON"]) + "-DODR_WITH_HTTP_SERVER=ON"]) run(["cmake", "--build", cmake_dir, "--target", "odr_jni", "--parallel", str(os.cpu_count() or 1)]) @@ -111,12 +108,6 @@ def build(architecture: str, conan: str, build_profile: str, output: Path) -> No shutil.copy2(source, target) strip(ndk, target) - # architecture independent, so the last ABI built simply wins - data = cmake_dir / "data" - assets = output / "assets" / "core" - shutil.rmtree(assets, ignore_errors=True) - shutil.copytree(data, assets / "odrcore") - def main() -> int: parser = argparse.ArgumentParser(description=__doc__, @@ -128,7 +119,7 @@ def main() -> int: parser.add_argument("--build-profile", default="default", help="conan build profile, i.e. the profile of this machine") parser.add_argument("--output", type=Path, default=ANDROID_ROOT / "native" / "prebuilt", - help="where to lay out jniLibs/ and assets/") + help="where to lay out jniLibs/") args = parser.parse_args() if not os.environ.get("ANDROID_HOME"): diff --git a/android/src/androidTest/java/app/opendocument/core/DocumentTest.kt b/android/src/androidTest/java/app/opendocument/core/DocumentTest.kt index d570ca36..79f9b873 100644 --- a/android/src/androidTest/java/app/opendocument/core/DocumentTest.kt +++ b/android/src/androidTest/java/app/opendocument/core/DocumentTest.kt @@ -25,7 +25,6 @@ class DocumentTest { @Before fun setUp() { - TestSupport.initialize() tempDir = TestSupport.tempDir("document") } diff --git a/android/src/androidTest/java/app/opendocument/core/HttpServerTest.kt b/android/src/androidTest/java/app/opendocument/core/HttpServerTest.kt index 98df946b..c6307a14 100644 --- a/android/src/androidTest/java/app/opendocument/core/HttpServerTest.kt +++ b/android/src/androidTest/java/app/opendocument/core/HttpServerTest.kt @@ -27,7 +27,6 @@ class HttpServerTest { @Before fun setUp() { - TestSupport.initialize() tempDir = TestSupport.tempDir("http-server") } diff --git a/android/src/androidTest/java/app/opendocument/core/LoggerTest.kt b/android/src/androidTest/java/app/opendocument/core/LoggerTest.kt index 27e1c7a4..cc0b056a 100644 --- a/android/src/androidTest/java/app/opendocument/core/LoggerTest.kt +++ b/android/src/androidTest/java/app/opendocument/core/LoggerTest.kt @@ -24,7 +24,6 @@ class LoggerTest { @Before fun setUp() { - TestSupport.initialize() tempDir = TestSupport.tempDir("logger") } diff --git a/android/src/androidTest/java/app/opendocument/core/OdrAndroidTest.kt b/android/src/androidTest/java/app/opendocument/core/OdrAndroidTest.kt index 8b723c8e..ae848472 100644 --- a/android/src/androidTest/java/app/opendocument/core/OdrAndroidTest.kt +++ b/android/src/androidTest/java/app/opendocument/core/OdrAndroidTest.kt @@ -1,23 +1,16 @@ package app.opendocument.core import androidx.test.ext.junit.runners.AndroidJUnit4 -import java.io.File +import app.opendocument.core.android.OdrAndroid import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull -import org.junit.Assert.assertTrue -import org.junit.Before import org.junit.Test import org.junit.runner.RunWith -/** The AAR's own contract: the native library loads and the bundled assets end up on disk. */ +/** The AAR's own contract: the native library loads and works without any setup. */ @RunWith(AndroidJUnit4::class) class OdrAndroidTest { - @Before - fun setUp() { - TestSupport.initialize() - } - @Test fun nativeLibraryLoads() { // reaching the native side at all means the .so, its ABI and libc++_shared @@ -28,18 +21,11 @@ class OdrAndroidTest { } @Test - fun assetsAreExtracted() { - val data = File(GlobalParams.odrCoreDataPath()) - assertTrue("$data is not a directory", data.isDirectory) - assertTrue(File(data, "document.css").isFile) - assertTrue(File(data, "document.js").isFile) - } - - @Test - fun initIsIdempotent() { - val dataPath = GlobalParams.odrCoreDataPath() - TestSupport.initialize() - assertEquals(dataPath, GlobalParams.odrCoreDataPath()) + @Suppress("DEPRECATION") + fun initIsANoOp() { + // it is still called by apps built against the versions that needed it + OdrAndroid.init(TestSupport.context()) + OdrAndroid.init(TestSupport.context()) } @Test diff --git a/android/src/androidTest/java/app/opendocument/core/TestSupport.kt b/android/src/androidTest/java/app/opendocument/core/TestSupport.kt index 8bb2ba1b..e7dc7644 100644 --- a/android/src/androidTest/java/app/opendocument/core/TestSupport.kt +++ b/android/src/androidTest/java/app/opendocument/core/TestSupport.kt @@ -2,20 +2,14 @@ package app.opendocument.core import android.content.Context import androidx.test.platform.app.InstrumentationRegistry -import app.opendocument.core.android.OdrAndroid import java.io.File import java.io.IOException import java.nio.file.Path -/** Shared setup of the instrumented suite: the initialised library and a scratch directory. */ +/** Shared setup of the instrumented suite. */ internal object TestSupport { fun context(): Context = InstrumentationRegistry.getInstrumentation().targetContext - /** The library, with its bundled assets extracted and registered. */ - fun initialize() { - OdrAndroid.init(context()) - } - /** An empty directory under the app cache, named after the caller. */ fun tempDir(name: String): Path { val directory = File(context().cacheDir, name) diff --git a/android/src/main/java/app/opendocument/core/android/OdrAndroid.kt b/android/src/main/java/app/opendocument/core/android/OdrAndroid.kt index 71ce4688..be66be0a 100644 --- a/android/src/main/java/app/opendocument/core/android/OdrAndroid.kt +++ b/android/src/main/java/app/opendocument/core/android/OdrAndroid.kt @@ -1,86 +1,23 @@ package app.opendocument.core.android import android.content.Context -import android.content.res.AssetManager -import app.opendocument.core.GlobalParams -import app.opendocument.core.Odr -import java.io.File -import java.io.FileOutputStream import java.io.IOException /** - * Makes the library usable on android: the renderer reads its CSS/JS as plain files, and an APK - * holds them as assets, which are not files. + * Android entry point of the library. * - * Call [init] once before anything else touches the library: - * ``` - * OdrAndroid.init(context) - * val file = Odr.open(path) - * ``` + * Nothing needs setting up any more: the renderer's CSS/JS are part of the native library, so the + * assets this used to extract are gone. [init] stays as a no-op so existing callers keep working. */ object OdrAndroid { - /** Asset directory this AAR ships its runtime data under. */ - private const val ASSETS = "core" - - /** The default 8 KB would be a lot of syscalls for assets this size. */ - private const val BUFFER_SIZE = 64 * 1024 - - private var initialized = false - /** - * Extracts the bundled runtime data and points the library at it. Repeated calls are cheap: the - * data is extracted once per version of the library and reused afterwards. + * Does nothing. * - * @param context any context; only the app's private storage and its assets are used - * @throws IOException if the assets cannot be extracted + * @param context unused */ + @Deprecated("The library needs no setup; this does nothing.") @JvmStatic @Synchronized @Throws(IOException::class) - fun init(context: Context) { - if (initialized) { - return - } - - // Keyed by the exact library build: an app that updates must not keep - // reading the assets of the version it had before, and there is no reason - // to back these up or restore them onto another device. - val root = File(context.noBackupFilesDir, "odr-core/${Odr.commitHash()}") - val marker = File(root, ".complete") - if (!marker.isFile) { - root.deleteRecursively() - extract(context.assets, ASSETS, root) - if (!marker.createNewFile()) { - throw IOException("could not mark $root as complete") - } - } - - GlobalParams.setOdrCoreDataPath(File(root, "odrcore").absolutePath) - initialized = true - } - - /** An asset directory lists its children; an asset file lists nothing. */ - private fun extract(assets: AssetManager, path: String, target: File) { - val children = assets.list(path) - if (children.isNullOrEmpty()) { - copy(assets, path, target) - return - } - if (!target.isDirectory && !target.mkdirs()) { - throw IOException("could not create $target") - } - for (child in children) { - extract(assets, "$path/$child", File(target, child)) - } - } - - private fun copy(assets: AssetManager, path: String, target: File) { - val parent = target.parentFile - if (parent != null && !parent.isDirectory && !parent.mkdirs()) { - throw IOException("could not create $parent") - } - assets.open(path).use { input -> - FileOutputStream(target).use { output -> input.copyTo(output, BUFFER_SIZE) } - } - } + fun init(context: Context) {} } diff --git a/apple/AGENTS.md b/apple/AGENTS.md index be11a4c5..f8a134ba 100644 --- a/apple/AGENTS.md +++ b/apple/AGENTS.md @@ -34,36 +34,21 @@ it — a simulator binary mistagged `IOS` is the classic | `macos-arm64_x86_64` | `apple-macos-armv8` + `apple-macos-x86_64` | `assemble` also fails the build if a framework is missing its headers, module -map, renderer resources or the plist's platform keys — the analogue of +map or the plist's platform keys — the analogue of `android/build.gradle.kts`'s `checkNative`, and for the same reason: those all publish happily and then fail at the consumer. ## Why a dynamic framework -`+load` in `src/OdrCoreBootstrap.mm` is what points odrcore at the css/js in -this bundle, so an app never has to. In a **static** framework nothing -references that translation unit, the linker drops it, and the bootstrap never -runs — and a SwiftPM binary target gives the consumer no way to pass -`-ObjC`/`-force_load` to get it back. Two lesser reasons: `.binaryTarget` -has no `resources:`, so the assets have to live in the bundle; and an undefined -symbol becomes a link error here instead of a crash at the consumer. - -Consequence to document for consumers: **do not enable mergeable libraries**. -Merging relocates the code into the app binary, `[NSBundle bundleForClass:]` -then returns the app bundle, and the bootstrap points at the wrong place. +An undefined symbol becomes a link error here instead of a crash at the +consumer, and a SwiftPM binary target gives the consumer no way to pass +`-ObjC`/`-force_load` for whatever a static archive would drop. ## Rules -- **`+load`, not lazy initialisation.** `HtmlConfig::init()` (`src/odr/html.cpp`) - *snapshots* `GlobalParams::odr_core_data_path()` when constructed, so a hook - that only fires on the first ObjC call is already too late for a caller that - reaches odrcore's C++ directly — which OpenDocument.ios does today. It is safe - this early: Foundation is in the image's `LC_LOAD_DYLIB`, and - `GlobalParams::instance()` is a function-local static. - **`ODR` is the public prefix, `OdrCore` the internal one.** The export list globs `_OBJC_CLASS_$_ODR*`, so an internal class named `ODR…` would become - public surface by accident. `OdrCoreBootstrap` is named the way it is for - exactly that reason. + public surface by accident. - **Export list, never `-fvisibility=hidden`** — the latter hides the ObjC class symbols too. Keep ivars out of the headers (properties only), or `_OBJC_IVAR_$_ODR*` has to go on the list as well. @@ -129,9 +114,9 @@ which would leave the tag serving the previous version's binary. The iOS *device* slice is only ever link-checked — nothing runs it. The simulator suite is the analogue of android's instrumented job and the only -place that sees what a device sees: that `+load` fired, that `NSBundle` found -the renderer assets, that `temp_directory_path()` is writable inside an app -container. A new binding is only covered once something in `tests/` calls it. +place that sees what a device sees: that the framework loads, that rendering +works with nothing configured, that `temp_directory_path()` is writable inside +an app container. A new binding is only covered once something in `tests/` calls it. `tests/Fixtures/mixed-layout.odt` is 9 KB of `odt/` from OpenDocument.test, carried here because `test/data/` is fetched by `cmake/test_data.cmake` and a diff --git a/apple/CMakeLists.txt b/apple/CMakeLists.txt index cb54fc50..52d53152 100644 --- a/apple/CMakeLists.txt +++ b/apple/CMakeLists.txt @@ -1,11 +1,5 @@ # The Objective-C bindings, built as a dynamic framework — the slice that # `build_xcframework.py` assembles into `OdrCoreObjC.xcframework`. -# -# Dynamic and not static, because `+load` (see `src/OdrCoreBootstrap.mm`) is what -# points odrcore at the resources in this bundle, and a static archive member -# nothing references is dropped by the linker. SwiftPM binary targets give a -# consumer no way to pass `-ObjC`/`-force_load`, so there is no recovering -# from that on their side. if (NOT APPLE) message(FATAL_ERROR "ODR_APPLE needs an Apple toolchain") @@ -15,12 +9,6 @@ if (BUILD_SHARED_LIBS) "ODR_APPLE needs BUILD_SHARED_LIBS=OFF: the framework is the only " "dylib, odrcore is linked into it") endif () -if (NOT ODR_BUNDLE_ASSETS) - message(FATAL_ERROR - "ODR_APPLE needs ODR_BUNDLE_ASSETS=ON: the renderer's css/js ship " - "as bundle resources") -endif () - enable_language(OBJCXX) # `CMAKE_OSX_SYSROOT` is the only thing that distinguishes the three slices, and @@ -64,15 +52,8 @@ set(ODR_APPLE_PUBLIC_HEADERS "include/OdrCoreObjC/ODRTable.h" ) -# The renderer's css/js, as the root build staged them. -file(GLOB ODR_APPLE_RESOURCES "${ODR_BUILD_ODR_DATA_PATH}/*") -if (NOT ODR_APPLE_RESOURCES) - message(FATAL_ERROR "no resources in ${ODR_BUILD_ODR_DATA_PATH}") -endif () - add_library(odr_apple SHARED "src/ODRInternal.mm" - "src/OdrCoreBootstrap.mm" "src/ODRGlobalParams.mm" "src/ODRDocument.mm" "src/ODRDocumentElement.mm" @@ -111,23 +92,22 @@ target_link_options(odr_apple PRIVATE ) set_property(TARGET odr_apple APPEND PROPERTY LINK_DEPENDS "${ODR_APPLE_EXPORTS}") -# Headers, Resources and Modules are staged by hand. +# Headers and Modules are staged by hand. # -# `PUBLIC_HEADER` and `RESOURCE` are the obvious way to do the first two, and -# on CMake 3.28 with the Ninja generator they copy nothing at all — the -# framework comes out with an empty `Resources` and no `Headers`, silently. A -# framework that builds and then cannot be imported, with the renderer's assets -# missing at runtime, is not a failure mode worth risking on a generator quirk. -# `Modules/` has no property to begin with. So all three are explicit. +# `PUBLIC_HEADER` is the obvious way to do the first, and on CMake 3.28 with the +# Ninja generator it copies nothing at all — the framework comes out without +# `Headers`, silently. A framework that builds and then cannot be imported is +# not a failure mode worth risking on a generator quirk. `Modules/` has no +# property to begin with. So both are explicit. # # The content directory is spelled out rather than taken from # `TARGET_BUNDLE_CONTENT_DIR`, which expands to the bundle root even on macOS, # where the content actually lives in `Versions/`. -# A framework is a *flat* bundle everywhere except macOS: its resources sit at -# the bundle root, not in `Resources/`. Staging them into a `Resources/` -# subdirectory there makes the bundle unloadable — `installd` refuses the app -# that embeds it with "Failed to load Info.plist from bundle", naming a plist -# that is present and valid. +# A framework is a *flat* bundle everywhere except macOS, where the versioned +# layout keeps `Info.plist` in `Resources/`. A `Resources/` subdirectory on iOS +# makes the bundle unloadable — `installd` refuses the app that embeds it with +# "Failed to load Info.plist from bundle", naming a plist that is present and +# valid. if (CMAKE_SYSTEM_NAME MATCHES "^(iOS|tvOS|watchOS|visionOS)$") set(ODR_APPLE_CONTENT "$") set(ODR_APPLE_RESOURCE_DIR "${ODR_APPLE_CONTENT}") @@ -152,10 +132,8 @@ add_custom_command(TARGET odr_apple POST_BUILD COMMAND "${CMAKE_COMMAND}" -E copy_if_different "${CMAKE_CURRENT_SOURCE_DIR}/module.modulemap" "${ODR_APPLE_CONTENT}/Modules" - COMMAND "${CMAKE_COMMAND}" -E copy_if_different - ${ODR_APPLE_RESOURCES} "${ODR_APPLE_RESOURCE_DIR}" WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" - COMMENT "Staging OdrCoreObjC.framework headers, module map and resources" + COMMENT "Staging OdrCoreObjC.framework headers and module map" VERBATIM ) diff --git a/apple/README.md b/apple/README.md index ed89e64e..4baa46d7 100644 --- a/apple/README.md +++ b/apple/README.md @@ -32,10 +32,8 @@ for view in service.views { } ``` -Nothing needs configuring first. The framework points odrcore at the css and JS -it carries before `main` runs — that is what the `+load` in -`OdrCoreBootstrap.mm` is for. Override it with `GlobalParams` from -`application(_:didFinishLaunchingWithOptions:)` if you relocated the resources. +Nothing needs configuring first: the renderer's css and JS are part of the +library and are written into the HTML it produces. ## Serve it into a web view @@ -43,11 +41,8 @@ Rendering on demand and serving over loopback is what OpenDocument.ios does, and it beats writing every page to disk up front. ```swift -let config = HtmlConfig() -config.relativeResourcePaths = false // odrcore rejects these in server mode - let service = try HtmlTranslator.translate( - file: file, cachePath: cacheDirectory, config: config) + file: file, cachePath: cacheDirectory, config: HtmlConfig()) let server = HttpServer() try server.connect(service, prefix: "doc") @@ -96,13 +91,6 @@ do { } ``` -## Do not enable mergeable libraries - -Merging relocates the framework's code into your app binary, at which point -`Bundle(for:)` returns the app bundle and the bundled resources are no longer -found. Embed and sign it as a normal dynamic framework, which is what SwiftPM -does by default. - ## Building it yourself ```bash diff --git a/apple/build_xcframework.py b/apple/build_xcframework.py index e1a18d60..c19cc13a 100755 --- a/apple/build_xcframework.py +++ b/apple/build_xcframework.py @@ -110,8 +110,7 @@ def build(profile: str, conan: str, build_profile: str) -> None: "-DODR_TEST=OFF", "-DODR_JNI=OFF", "-DODR_PYTHON=OFF", - "-DODR_WITH_HTTP_SERVER=ON", - "-DODR_BUNDLE_ASSETS=ON"]) + "-DODR_WITH_HTTP_SERVER=ON"]) run(["cmake", "--build", cmake_dir, "--target", "odr_apple", "--parallel", str(os.cpu_count() or 1)]) @@ -146,19 +145,18 @@ def assert_install_name(binary: Path) -> None: def assert_contents(framework: Path) -> None: - """A framework missing its headers, module map or resources builds and - publishes happily and then fails at the consumer, so make it a build error - here — the same reason `android/build.gradle.kts` has `checkNative`.""" + """A framework missing its headers or module map builds and publishes + happily and then fails at the consumer, so make it a build error here — the + same reason `android/build.gradle.kts` has `checkNative`.""" root = framework / "Versions" / "A" if not root.exists(): root = framework - # Resources are flat on iOS and under `Resources/` only in the versioned + # `Info.plist` is flat on iOS and under `Resources/` only in the versioned # macOS layout — a bundle that gets that wrong does not load at all. resources = root / "Resources" if (root / "Resources").is_dir() else root required = [ root / "Headers" / f"{FRAMEWORK}.h", root / "Modules" / "module.modulemap", - resources / "document.css", ] missing = [path for path in required if not path.exists()] if missing: diff --git a/apple/include/OdrCoreObjC/ODRGlobalParams.h b/apple/include/OdrCoreObjC/ODRGlobalParams.h index 0c348d50..e640ca7a 100644 --- a/apple/include/OdrCoreObjC/ODRGlobalParams.h +++ b/apple/include/OdrCoreObjC/ODRGlobalParams.h @@ -2,16 +2,17 @@ NS_ASSUME_NONNULL_BEGIN -/// Where odrcore looks for the files it needs at runtime. +/// Runtime paths odrcore no longer needs. /// -/// The framework points `odrCoreDataPath` at its own bundle before `main` runs, -/// so an app that ships this framework unmodified never has to call anything -/// here. Set it only to override that — from -/// `application:didFinishLaunching...`, which is late enough to win. +/// Nothing here has any effect: the renderer's css and js are part of the +/// library, and detection needs no database. Both properties still store and +/// return what is set, so a caller that configures them keeps working. NS_SWIFT_NAME(GlobalParams) @interface ODRGlobalParams : NSObject -/// The css and js of the HTML renderer. +/// Where the css and js of the HTML renderer used to be read from. +/// +/// Deprecated and inert: they are written into the generated HTML now. @property(class, nonatomic, copy) NSString *odrCoreDataPath; /// The libmagic database (`magic.mgc`). /// @@ -20,9 +21,10 @@ NS_SWIFT_NAME(GlobalParams) /// is odrcore's own now and needs no database. @property(class, nonatomic, copy) NSString *libmagicDatabasePath; -/// Points `odrCoreDataPath` at this framework's bundle. Runs automatically at -/// load; public because a consumer who relocated the resources — or reset the -/// path and wants the default back — needs a way to redo it. +/// Points `odrCoreDataPath` at this framework's bundle. +/// +/// Deprecated and inert: the framework carries no resources any more. Kept so +/// a caller that set the path up itself keeps compiling. + (void)bootstrapFromFrameworkBundle; - (instancetype)init NS_UNAVAILABLE; diff --git a/apple/src/OdrCoreBootstrap.mm b/apple/src/OdrCoreBootstrap.mm deleted file mode 100644 index 6e15ef10..00000000 --- a/apple/src/OdrCoreBootstrap.mm +++ /dev/null @@ -1,26 +0,0 @@ -#import - -/// Points odrcore at the resources this framework carries, before `main`. -/// -/// `+load` rather than lazy initialisation on first use, because -/// `HtmlConfig::init()` *snapshots* `GlobalParams::odr_core_data_path()` when -/// it is constructed: a caller reaching odrcore's C++ directly — which -/// OpenDocument.ios does today — would otherwise get a config built from an -/// empty path. It is safe this far up: Foundation is in this image's -/// `LC_LOAD_DYLIB`, so `NSBundle` is live by the time dyld runs `+load`, and -/// `GlobalParams::instance()` is a function-local static, so nothing needs to -/// have been constructed first. -/// -/// This only works because the framework is a *dynamic* one. In a static -/// framework nothing references this translation unit, the linker drops it, -/// and `+load` never runs. -@interface OdrCoreBootstrap : NSObject -@end - -@implementation OdrCoreBootstrap - -+ (void)load { - [ODRGlobalParams bootstrapFromFrameworkBundle]; -} - -@end diff --git a/apple/tests/OdrCoreTests.swift b/apple/tests/OdrCoreTests.swift index 8a4e7184..fe9e2422 100644 --- a/apple/tests/OdrCoreTests.swift +++ b/apple/tests/OdrCoreTests.swift @@ -22,19 +22,7 @@ private func temporaryDirectory() throws -> String { return directory.path } -final class BootstrapTests: XCTestCase { - /// The whole point of the dynamic framework: `+load` pointed odrcore at the - /// bundled resources before `main`, with nothing in this test calling it. If - /// this fails, every rendering test fails too, but for a reason that would be - /// much harder to read off. - func testResourcesAreWiredUpWithoutAnyoneAskingFor() { - let path = GlobalParams.odrCoreDataPath - XCTAssertFalse(path.isEmpty, "odr core data path was never set") - XCTAssertTrue( - FileManager.default.fileExists(atPath: path + "/document.css"), - "\(path) does not contain the renderer's css") - } - +final class LibraryTests: XCTestCase { func testLibraryIdentifiesItself() { XCTAssertFalse(Odr.identification.isEmpty) XCTAssertFalse(Odr.commitHash.isEmpty) @@ -108,11 +96,8 @@ final class DecodeTests: XCTestCase { final class HtmlTests: XCTestCase { private func service() throws -> HtmlService { let file = try DecodedFile.decode(path: try Fixture.odt()) - let config = HtmlConfig() - // odrcore rejects relative resource paths when the output is served - config.relativeResourcePaths = false return try HtmlTranslator.translate( - file: file, cachePath: try temporaryDirectory(), config: config) + file: file, cachePath: try temporaryDirectory(), config: HtmlConfig()) } func testRendersHtml() throws { @@ -124,9 +109,13 @@ final class HtmlTests: XCTestCase { XCTAssertTrue(html.contains("Landscape"), "the document text is missing") } - /// The default config must already point at the framework's own resources. - func testDefaultConfigUsesBundledResources() { - XCTAssertEqual(HtmlConfig().resourcePath, GlobalParams.odrCoreDataPath) + /// The renderer's css and js are part of the library, so a document renders + /// with nothing configured and carries its own styles. + func testRenderedHtmlCarriesItsOwnStyles() throws { + let view = try XCTUnwrap(try service().views.first) + var resources: NSArray? + let html = try view.writeHtml(resources: &resources) + XCTAssertTrue(html.contains(" pages = html.pages(); assertEquals(1, pages.size()); @@ -95,7 +91,6 @@ void translateText() throws IOException { @Test void translateCsv() throws IOException { - assumeTrue(TestFiles.hasCoreData(), "odr core data path not available"); Html html = translateOffline(TestFiles.csvFile(tempDir)); assertEquals(1, html.pages().size()); String content = Files.readString(Path.of(html.pages().get(0).path)); @@ -104,7 +99,6 @@ void translateCsv() throws IOException { @Test void translateDocument() throws IOException { - assumeTrue(TestFiles.hasCoreData(), "odr core data path not available"); Html html = translateOffline(TestFiles.odtFile(tempDir)); assertEquals(1, html.pages().size()); String content = Files.readString(Path.of(html.pages().get(0).path)); @@ -113,7 +107,6 @@ void translateDocument() throws IOException { @Test void htmlServiceViews() throws IOException { - assumeTrue(TestFiles.hasCoreData(), "odr core data path not available"); Path cache = Files.createDirectories(tempDir.resolve("cache")); DecodedFile file = Odr.open(TestFiles.odtFile(tempDir).toString()); HtmlService service = Html.translate(file, cache.toString(), new HtmlConfig()); diff --git a/jni/tests/app/opendocument/core/HttpServerTest.java b/jni/tests/app/opendocument/core/HttpServerTest.java index 96a5f8a6..6387fe30 100644 --- a/jni/tests/app/opendocument/core/HttpServerTest.java +++ b/jni/tests/app/opendocument/core/HttpServerTest.java @@ -57,7 +57,6 @@ private static Response fetch(String url) throws Exception { @Test void serveFile() throws Exception { assumeTrue(Odr.hasHttpServer(), "built without the HTTP server"); - assumeTrue(TestFiles.hasCoreData(), "odr core data path not available"); HttpServer server = new HttpServer(); @@ -66,7 +65,6 @@ void serveFile() throws Exception { DecodedFile file = Odr.open(TestFiles.odtFile(tempDir).toString()); HtmlConfig htmlConfig = new HtmlConfig(); htmlConfig.embedImages = false; - htmlConfig.relativeResourcePaths = false; HtmlService service = Html.translate(file, cachePath, htmlConfig); server.connectService(service, "doc"); List views = service.listViews(); diff --git a/pyproject.toml b/pyproject.toml index 6c40e8a6..9ef79b56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,11 +61,6 @@ ODR_PYTHON = "ON" ODR_CLI = "OFF" ODR_TEST = "OFF" BUILD_SHARED_LIBS = "OFF" -# Stages the renderer's css/js so `python/CMakeLists.txt` can install them as -# `pyodr/data`. The wheel used to carry `magic.mgc` here too (~0.4 MB deflated -# of 8.5 MB); libmagic is gone and `mimetype` needs no database. -ODR_BUNDLE_ASSETS = "ON" - # Section must exist for setuptools-scm to pick up its pyproject config. [tool.setuptools_scm] diff --git a/python/CMakeLists.txt b/python/CMakeLists.txt index 89acc0ff..648563e6 100644 --- a/python/CMakeLists.txt +++ b/python/CMakeLists.txt @@ -53,28 +53,15 @@ add_custom_target(pyodr_package COMMAND "${CMAKE_COMMAND}" -E copy_directory "${CMAKE_CURRENT_SOURCE_DIR}/pyodr" "${CMAKE_CURRENT_BINARY_DIR}/pyodr" ) -if (DEFINED ODR_BUILD_ODR_DATA_PATH) - add_custom_command(TARGET pyodr_package POST_BUILD - COMMAND "${CMAKE_COMMAND}" -E copy_directory - "${ODR_BUILD_ODR_DATA_PATH}" "${CMAKE_CURRENT_BINARY_DIR}/pyodr/data" - ) -endif () add_dependencies(pyodr_core pyodr_package) install(TARGETS pyodr_core LIBRARY DESTINATION pyodr COMPONENT python) install(DIRECTORY pyodr/ DESTINATION pyodr COMPONENT python FILES_MATCHING PATTERN "*.py" PATTERN "py.typed") -# Bundle the odr.js/css assets so the wheel is self-contained; -# `pyodr/__init__.py` points `GlobalParams` at them. -if (DEFINED ODR_BUILD_ODR_DATA_PATH) - install(DIRECTORY "${ODR_BUILD_ODR_DATA_PATH}/" DESTINATION pyodr/data - COMPONENT python) -endif () - if (ODR_TEST) enable_testing() add_test(NAME pyodr_pytest COMMAND Python::Interpreter -m pytest "${CMAKE_CURRENT_SOURCE_DIR}/tests" -v) set_tests_properties(pyodr_pytest PROPERTIES - ENVIRONMENT "PYTHONPATH=${CMAKE_CURRENT_BINARY_DIR};ODR_CORE_DATA_PATH=${ODR_BUILD_ODR_DATA_PATH}") + ENVIRONMENT "PYTHONPATH=${CMAKE_CURRENT_BINARY_DIR}") endif () diff --git a/python/README.md b/python/README.md index fef1bfc7..057ab9b7 100644 --- a/python/README.md +++ b/python/README.md @@ -27,25 +27,24 @@ The bindings are part of the main CMake build, toggled by `ODR_PYTHON`: conan install . -o '&:with_python=True' --build missing cmake -B build -S . -DCMAKE_TOOLCHAIN_FILE=conan_toolchain.cmake -DODR_PYTHON=ON cmake --build build --target pyodr_core -PYTHONPATH=build/python ODR_CORE_DATA_PATH=build/data python -m pytest python/tests +PYTHONPATH=build/python python -m pytest python/tests ``` `pip install .` from the repository root builds a wheel via scikit-build-core (see the root `pyproject.toml`); run `conan install` first and point `CMAKE_ARGS` at the generated `conan_toolchain.cmake` so the C++ dependencies -resolve, and match the wheel's asset bundling: +resolve: ```bash -conan install . -o '&:with_python=True' -o '&:bundle_assets=True' --build missing +conan install . -o '&:with_python=True' --build missing CMAKE_ARGS="-DCMAKE_TOOLCHAIN_FILE=$PWD/conan_toolchain.cmake" pip install . ``` ## Runtime data -Rendering uses shipped assets (CSS/JS). Wheels bundle them under `pyodr/data` -and pick them up automatically; for in-tree builds set `ODR_CORE_DATA_PATH` (the -tests read it) or call `pyodr.GlobalParams.set_odr_core_data_path(...)`. Missing -assets are not fatal — rendering then fails on the individual resource. +There is none. The renderer's css and js are part of the library, so rendering +works out of the box; `odr_core_data_path()` and `set_odr_core_data_path(...)` +are deprecated leftovers that still store and return a path nothing reads. MIME detection needs no runtime data: `mimetype` runs the open strategy, so it names what is *inside* a zip or a compound file. `libmagic_database_path()` and diff --git a/python/pyodr/__init__.py b/python/pyodr/__init__.py index 3ca918b7..63ec8703 100644 --- a/python/pyodr/__init__.py +++ b/python/pyodr/__init__.py @@ -11,42 +11,8 @@ >>> [page.path for page in html.pages()] """ -import os -from pathlib import Path - from pyodr import _core from pyodr._core import * # noqa: F401,F403 from pyodr._core import html # noqa: F401 __version__ = _core.version() - - -# Runtime assets installed into the package by `python/CMakeLists.txt`. -_BUNDLED_DATA_PATH = Path(__file__).resolve().parent / "data" - - -def _is_configured(path: str) -> bool: - # The compiled-in default is an install-relative guess ("share"). Resolving - # that against the process working directory would make the outcome depend - # on where the interpreter was launched, so only an absolute path that - # exists counts as already configured. - return bool(path) and os.path.isabs(path) and os.path.exists(path) - - -def _init_odr_core_data_path() -> None: - # Rendering needs the shipped odr.js/css assets. Resolution order: an - # already-configured path wins, then the ODR_CORE_DATA_PATH environment - # variable, then the assets bundled with the package. - if _is_configured(_core.GlobalParams.odr_core_data_path()): - return - env_path = os.environ.get("ODR_CORE_DATA_PATH") - if env_path: - _core.GlobalParams.set_odr_core_data_path(env_path) - return - if _BUNDLED_DATA_PATH.is_dir(): - _core.GlobalParams.set_odr_core_data_path(str(_BUNDLED_DATA_PATH)) - - -# `GlobalParams.libmagic_database_path` is left alone: libmagic is gone, so -# there is no database to resolve and nothing that would read one. -_init_odr_core_data_path() diff --git a/python/pyodr/cli.py b/python/pyodr/cli.py index a578b814..b4c0008c 100644 --- a/python/pyodr/cli.py +++ b/python/pyodr/cli.py @@ -66,7 +66,6 @@ def _serve(args, file) -> int: html_config = pyodr.HtmlConfig() html_config.embed_images = False - html_config.relative_resource_paths = False prefix = "file" views = server.serve_file(file, prefix, html_config) diff --git a/python/tests/conftest.py b/python/tests/conftest.py index f0d67262..9fdc0bf3 100644 --- a/python/tests/conftest.py +++ b/python/tests/conftest.py @@ -1,26 +1,7 @@ -import os import zipfile import pytest -import pyodr - - -def pytest_configure(config): - data_path = os.environ.get("ODR_CORE_DATA_PATH") - if data_path: - pyodr.GlobalParams.set_odr_core_data_path(data_path) - - -@pytest.fixture -def core_data_path(): - """Skip tests that render HTML when the shipped assets are unavailable.""" - path = pyodr.GlobalParams.odr_core_data_path() - if not path or not os.path.isdir(path): - pytest.skip("odr core data path not available (set ODR_CORE_DATA_PATH)") - return path - - ODT_CONTENT_XML = """ ' in render("raw", raw) -def test_translate_text(core_data_path, txt_path, tmp_path): +def test_translate_text(txt_path, tmp_path): html = translate_offline(txt_path, tmp_path) pages = html.pages() assert len(pages) == 1 @@ -78,7 +78,7 @@ def test_translate_text(core_data_path, txt_path, tmp_path): assert "hello text file" in content -def test_translate_csv(core_data_path, csv_path, tmp_path): +def test_translate_csv(csv_path, tmp_path): html = translate_offline(csv_path, tmp_path) pages = html.pages() assert len(pages) == 1 @@ -86,7 +86,7 @@ def test_translate_csv(core_data_path, csv_path, tmp_path): assert "alpha" in content -def test_translate_document(core_data_path, odt_path, tmp_path): +def test_translate_document(odt_path, tmp_path): html = translate_offline(odt_path, tmp_path) pages = html.pages() assert len(pages) == 1 @@ -94,7 +94,7 @@ def test_translate_document(core_data_path, odt_path, tmp_path): assert "Hello from pyodr!" in content -def test_html_service_views(core_data_path, odt_path, tmp_path): +def test_html_service_views(odt_path, tmp_path): file = pyodr.open(str(odt_path)) cache = tmp_path / "cache" cache.mkdir() @@ -108,7 +108,7 @@ def test_html_service_views(core_data_path, odt_path, tmp_path): assert isinstance(resources, list) -def test_html_view_outlives_service(core_data_path, odt_path, tmp_path): +def test_html_view_outlives_service(odt_path, tmp_path): file = pyodr.open(str(odt_path)) cache = tmp_path / "cache" cache.mkdir() diff --git a/python/tests/test_http_server.py b/python/tests/test_http_server.py index 0e6a4119..e7951843 100644 --- a/python/tests/test_http_server.py +++ b/python/tests/test_http_server.py @@ -20,7 +20,7 @@ def fetch(url, timeout=5.0): @pytest.mark.skipif(not pyodr.has_http_server, reason="built without the HTTP server") -def test_serve_file(core_data_path, odt_path, tmp_path): +def test_serve_file(odt_path, tmp_path): server = pyodr.HttpServer() # the server hosts what it is given; translating is the caller's business @@ -29,7 +29,6 @@ def test_serve_file(core_data_path, odt_path, tmp_path): file = pyodr.open(str(odt_path)) html_config = pyodr.HtmlConfig() html_config.embed_images = False - html_config.relative_resource_paths = False service = pyodr.html.translate(file, str(cache_path), html_config) server.connect_service(service, "doc") views = service.list_views() diff --git a/src/odr/global_params.cpp b/src/odr/global_params.cpp index d7a5c11c..4d64f433 100644 --- a/src/odr/global_params.cpp +++ b/src/odr/global_params.cpp @@ -1,7 +1,5 @@ #include -#include - namespace odr { GlobalParams &GlobalParams::instance() { @@ -26,9 +24,8 @@ void GlobalParams::set_libmagic_database_path(const std::string &path) { instance().m_libmagic_database_path = path; } -// `m_libmagic_database_path` starts empty: nothing reads it, and there is no -// longer a database path to default it to. -GlobalParams::GlobalParams() - : m_odr_core_data_path{internal::project_info::odr_data_path()} {} +// Both paths start empty: nothing reads either of them, and there is no longer +// anything on disk to default them to. +GlobalParams::GlobalParams() = default; } // namespace odr diff --git a/src/odr/global_params.hpp b/src/odr/global_params.hpp index 1db36dd0..358c5503 100644 --- a/src/odr/global_params.hpp +++ b/src/odr/global_params.hpp @@ -6,12 +6,16 @@ namespace odr { class GlobalParams final { public: + /// @deprecated Inert: the css and js are part of the library and nothing is + /// read from disk. It still stores and returns whatever is set, so a caller + /// that points it somewhere keeps working. static const std::string &odr_core_data_path(); /// @deprecated Inert: libmagic is gone and nothing reads this. It still /// stores and returns whatever is set, so a caller that sets it keeps /// working — detection is our own now, and looks at nothing outside the file. static const std::string &libmagic_database_path(); + /// @deprecated See @ref odr_core_data_path. static void set_odr_core_data_path(const std::string &path); /// @deprecated See @ref libmagic_database_path. static void set_libmagic_database_path(const std::string &path); diff --git a/src/odr/html.cpp b/src/odr/html.cpp index 55453907..35db76d5 100644 --- a/src/odr/html.cpp +++ b/src/odr/html.cpp @@ -33,8 +33,8 @@ namespace { void bring_offline(const HtmlResources &resources, const std::string &output_path) { for (const auto &[resource, location] : resources) { - if (!location.has_value() || resource.is_shipped() || - resource.is_external() || !resource.is_accessible()) { + if (!location.has_value() || resource.is_external() || + !resource.is_accessible()) { continue; } const Path path = Path(output_path).join(RelPath(*location)); @@ -237,20 +237,10 @@ HtmlResourceLocator html::standard_resource_locator() { return resource.path(); } - if ((config.embed_shipped_resources && resource.is_shipped()) || - (config.embed_images && resource.type() == HtmlResourceType::image)) { + if (config.embed_images && resource.type() == HtmlResourceType::image) { return std::nullopt; } - if (resource.is_shipped()) { - Path resource_path = - Path(config.resource_path).join(RelPath(resource.path())); - if (config.relative_resource_paths && config.output_path.has_value()) { - resource_path = resource_path.rebase(Path(*config.output_path)); - } - return resource_path.string(); - } - return resource.path(); }; } diff --git a/src/odr/html.hpp b/src/odr/html.hpp index 0f6c06c9..9ba69a08 100644 --- a/src/odr/html.hpp +++ b/src/odr/html.hpp @@ -43,6 +43,8 @@ class HtmlResource final { [[nodiscard]] const std::string &name() const; [[nodiscard]] const std::string &path() const; [[nodiscard]] const std::optional &file() const; + /// @deprecated Inert: always false. The css and js are written into the + /// document, so no resource is shipped alongside it any more. [[nodiscard]] bool is_shipped() const; [[nodiscard]] bool is_external() const; [[nodiscard]] bool is_accessible() const; @@ -112,10 +114,14 @@ struct HtmlConfig { // embedding bool embed_images{true}; + /// @deprecated Inert: nothing is shipped any more, the css and js are + /// written into the document. bool embed_shipped_resources{true}; // resources + /// @deprecated See @ref embed_shipped_resources. std::string resource_path; + /// @deprecated See @ref embed_shipped_resources. bool relative_resource_paths{true}; // create editable output diff --git a/src/odr/http_server.cpp b/src/odr/http_server.cpp index fbdec049..7367b3f1 100644 --- a/src/odr/http_server.cpp +++ b/src/odr/http_server.cpp @@ -380,15 +380,6 @@ void HttpServer::connect_service(HtmlService service, throw InvalidPrefix(prefix); } - if (service.config().relative_resource_paths) { - throw UnsupportedOption( - "relative_resource_paths cannot be enabled in server mode"); - } - if (!service.config().embed_shipped_resources) { - throw UnsupportedOption( - "embed_shipped_resources must be enabled in server mode"); - } - m_impl->connect_service(std::move(service), prefix); } diff --git a/src/odr/internal/html/document.cpp b/src/odr/internal/html/document.cpp index c98d49b2..26df47f9 100644 --- a/src/odr/internal/html/document.cpp +++ b/src/odr/internal/html/document.cpp @@ -9,13 +9,12 @@ #include #include -#include #include #include #include +#include #include #include -#include #include #include @@ -43,43 +42,9 @@ void front(const Document &document, const WritingState &state) { ? state.config().spreadsheet_viewport_mode : std::nullopt); - auto document_css_file = File(AbsPath(state.config().resource_path) - .join(RelPath("document.css")) - .string()); - odr::HtmlResource document_css_resource = HtmlResource::create( - HtmlResourceType::css, "text/css", "document.css", "document.css", - document_css_file, true, false, true); - HtmlResourceLocation document_css_location = - state.config().resource_locator(document_css_resource, state.config()); - state.resources().emplace_back(std::move(document_css_resource), - document_css_location); - if (document_css_location.has_value()) { - out.write_header_style(document_css_location.value()); - } else { - out.write_header_style_begin(); - util::stream::pipe(*document_css_file.stream(), out.out()); - out.write_header_style_end(); - } - + write_document_style(out); if (document.document_type() == DocumentType::spreadsheet) { - auto spreadsheet_css_file = File(AbsPath(state.config().resource_path) - .join(RelPath("spreadsheet.css")) - .string()); - odr::HtmlResource spreadsheet_css_resource = HtmlResource::create( - HtmlResourceType::css, "text/css", "spreadsheet.css", "spreadsheet.css", - spreadsheet_css_file, true, false, true); - HtmlResourceLocation spreadsheet_css_location = - state.config().resource_locator(spreadsheet_css_resource, - state.config()); - state.resources().emplace_back(std::move(spreadsheet_css_resource), - spreadsheet_css_location); - if (spreadsheet_css_location.has_value()) { - out.write_header_style(spreadsheet_css_location.value()); - } else { - out.write_header_style_begin(); - util::stream::pipe(*spreadsheet_css_file.stream(), out.out()); - out.write_header_style_end(); - } + write_spreadsheet_style(out); } out.write_header_end(); @@ -123,23 +88,7 @@ void back(const Document &document, const WritingState &state) { out.write_element_end("div"); } - auto document_js_file = File(AbsPath(state.config().resource_path) - .join(RelPath("document.js")) - .string()); - odr::HtmlResource document_js_resource = HtmlResource::create( - HtmlResourceType::js, "text/javascript", "document.js", "document.js", - document_js_file, true, false, true); - HtmlResourceLocation document_js_location = - state.config().resource_locator(document_js_resource, state.config()); - state.resources().emplace_back(std::move(document_js_resource), - document_js_location); - if (document_js_location.has_value()) { - out.write_script(document_js_location.value()); - } else { - out.write_script_begin(); - util::stream::pipe(*document_js_file.stream(), out.out()); - out.write_script_end(); - } + write_document_script(out); out.write_body_end(); out.write_end(); diff --git a/src/odr/internal/html/frontend.cpp b/src/odr/internal/html/frontend.cpp new file mode 100644 index 00000000..947a9099 --- /dev/null +++ b/src/odr/internal/html/frontend.cpp @@ -0,0 +1,593 @@ +#include + +#include + +namespace odr::internal::html { + +namespace { + +constexpr const char *document_css = R"css( +*{margin:0;position:relative} +body{padding:5px} +x-p{display:block;font-size:0} +x-s{display:inline} +.odr-background{padding:0;background:#525659} +/* The page column, sized to the widest page so pages of differing width centre + against each other, not against the viewport. The page's side margin is part + of that width, so fitting the document to a phone screen leaves a gutter + instead of going edge to edge. */ +.odr-pages{display:flex;flex-direction:column;align-items:center;gap:16px;padding:16px 0;width:max-content;min-width:100%} +.odr-page-outer{display:flex;margin:0 16px;background:#fff;box-shadow:0 1px 4px rgba(0,0,0,.5);z-index:-1000} +mark{background:#ff0} +mark.current{background:orange} +)css"; + +constexpr const char *spreadsheet_css = R"css( +table{border-collapse:collapse;table-layout:fixed} +td{vertical-align:bottom;text-overflow:ellipsis;height:inherit} +x-p{font-family:"Arial",serif;font-size:10pt} +td x-p{height:inherit} +.odr-gridlines-soft table td{border-top:1px solid #c0c0c0;border-left:1px solid #c0c0c0} +.odr-gridlines-hard table td{border:1px solid #c0c0c0!important} +table td.odr-value-type-float{text-align:right} +)css"; + +constexpr const char *text_css = R"css( +.odr-text{display:flex;flex-direction:row;font-family:monospace} +.odr-text-nr{display:flex;flex-direction:column;text-align:right;vertical-align:top;color:#999;border-right:solid #999} +.odr-text-body{display:flex;flex-direction:column;padding-left:5pt;white-space:pre} +.odr-text-wrap{white-space:break-spaces;word-break:break-word;overflow-wrap:anywhere} +[contenteditable]:focus{outline:none} +)css"; + +constexpr const char *document_js = R"js( +(function () { + "use strict"; + + var odr = (window.odr = window.odr || {}); + + odr.onError = function (code, message) { + console.error("error " + code + " message " + message); + }; + + var errorIllegalEditNewLine = { + code: 1, + message: "new line not supported by this document", + }; + + var modified = {}; + + odr.generateDiff = function () { + var result = { modifiedText: {} }; + for (var path in modified) { + if (Object.prototype.hasOwnProperty.call(modified, path)) { + result.modifiedText[path] = modified[path].innerText; + } + } + return JSON.stringify(result); + }; + + new MutationObserver(function (mutations) { + for (var i = 0; i < mutations.length; ++i) { + if (mutations[i].type !== "characterData") { + continue; + } + // The nearest owner, not the direct parent: a search `` may sit + // between the edited text and the element carrying the path. + var parent = mutations[i].target.parentElement; + var owner = parent && parent.closest("[data-odr-path]"); + if (owner) { + modified[owner.getAttribute("data-odr-path")] = owner; + } + } + }).observe(document.body, { + childList: true, + subtree: true, + characterData: true, + }); + + document.addEventListener("keydown", function (event) { + if (event.key === "Enter") { + event.preventDefault(); + odr.onError(errorIllegalEditNewLine.code, errorIllegalEditNewLine.message); + } + }); + + var marks = []; + var current = -1; + var keyword = ""; + + // Case- and diacritic-folded `text` plus a folded-index to source-index map + // (with an end sentinel), so a match maps back onto the source string. + // Folding per character is what keeps that map right when a character folds + // to none or to several. + function fold(text) { + var folded = ""; + var map = []; + for (var i = 0; i < text.length; ++i) { + var character = text[i] + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase(); + for (var j = 0; j < character.length; ++j) { + map.push(i); + } + folded += character; + } + map.push(text.length); + return { text: folded, map: map }; + } + + function textNodes() { + var walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, { + acceptNode: function (node) { + var name = node.parentNode ? node.parentNode.nodeName : ""; + if (name === "SCRIPT" || name === "STYLE" || name === "MARK") { + return NodeFilter.FILTER_REJECT; + } + return node.nodeValue.length > 0 + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_REJECT; + }, + }); + var nodes = []; + while (walker.nextNode()) { + nodes.push(walker.currentNode); + } + return nodes; + } + + function markNode(node, needle) { + var found = []; + while (true) { + var folded = fold(node.nodeValue); + var at = folded.text.indexOf(needle); + if (at === -1) { + return found; + } + var match = node.splitText(folded.map[at]); + node = match.splitText(folded.map[at + needle.length] - folded.map[at]); + var mark = document.createElement("mark"); + mark.className = "highlight"; + match.parentNode.replaceChild(mark, match); + mark.appendChild(match); + found.push(mark); + } + } + + function select(index) { + if (current >= 0 && marks[current]) { + marks[current].classList.remove("current"); + } + current = index; + marks[current].classList.add("current"); + marks[current].scrollIntoView({ block: "center", inline: "center" }); + } + + function step(delta, next) { + if (next !== undefined && next !== null && fold(String(next)).text !== keyword) { + return odr.search(next); + } + if (marks.length === 0) { + return 0; + } + select((current + delta + marks.length) % marks.length); + return marks.length; + } + + odr.resetSearch = function () { + for (var i = 0; i < marks.length; ++i) { + var parent = marks[i].parentNode; + if (!parent) { + continue; + } + while (marks[i].firstChild) { + parent.insertBefore(marks[i].firstChild, marks[i]); + } + parent.removeChild(marks[i]); + parent.normalize(); + } + marks = []; + current = -1; + keyword = ""; + }; + + // Highlights every occurrence, selects the first and returns the count. + odr.search = function (text) { + odr.resetSearch(); + keyword = fold(text === undefined || text === null ? "" : String(text)).text; + if (keyword === "") { + return 0; + } + var nodes = textNodes(); + for (var i = 0; i < nodes.length; ++i) { + marks = marks.concat(markNode(nodes[i], keyword)); + } + if (marks.length > 0) { + select(0); + } + return marks.length; + }; + + // An argument is searched for first unless it is already the highlighted + // keyword, so a host can drive search and step from the same string. + odr.searchNext = function (text) { + return step(1, text); + }; + + odr.searchPrevious = function (text) { + return step(-1, text); + }; +})(); +)js"; + +/// Every input is applied to the line `
`s by hand, so the line numbers +/// stay in step and undo/redo replay changes instead of the browser's history. +constexpr const char *text_js = R"js( +(function () { + "use strict"; + + function TextEditor(textNr, textBody) { + this.textNr = textNr; + this.textBody = textBody; + this.past = []; + this.future = []; + + var self = this; + + new ResizeObserver(function () { + self.updateLineNumberHeight(); + }).observe(this.textBody); + + this.textBody.addEventListener("input", function () { + var nrCount = self.textNr.querySelectorAll("div").length; + var lineCount = self.textBody.querySelectorAll("div").length; + for (var i = nrCount + 1; i <= lineCount; ++i) { + var nrCell = document.createElement("div"); + nrCell.textContent = String(i); + self.textNr.appendChild(nrCell); + } + for (var j = nrCount; j > lineCount; --j) { + self.textNr.removeChild(self.textNr.lastChild); + } + self.updateLineNumberHeight(); + }); + + this.textBody.addEventListener("beforeinput", function (event) { + event.preventDefault(); + + if (event.inputType === "historyUndo") { + self.undo(); + } else if (event.inputType === "historyRedo") { + self.redo(); + } else if (event.inputType === "insertText") { + self.insertTextAction(event.data); + } else if (event.inputType === "insertParagraph") { + self.insertTextAction("\n"); + } else if (event.inputType === "deleteContentBackward") { + self.removeTextAction("backward"); + } else if (event.inputType === "deleteContentForward") { + self.removeTextAction("forward"); + } + }); + + this.textBody.addEventListener("paste", function (event) { + event.preventDefault(); + self.insertTextAction(event.clipboardData.getData("text/plain")); + }); + + this.textBody.addEventListener("drop", function (event) { + event.preventDefault(); + }); + + this.textBody.addEventListener("dragover", function (event) { + event.preventDefault(); + }); + } + + TextEditor.prototype.updateLineNumberHeight = function () { + var nrCells = this.textNr.querySelectorAll("div"); + var textCells = this.textBody.querySelectorAll("div"); + for (var i = 0; i < textCells.length && i < nrCells.length; ++i) { + nrCells[i].style.height = textCells[i].offsetHeight + "px"; + } + }; + + // Lines are the element children: formatted output puts a whitespace text + // node between them, and counting or indexing those as lines is off by as + // much as a factor of two. + TextEditor.prototype.getPosition = function (container, offset) { + var line = container.nodeName === "DIV" ? container : container.parentNode; + return { + line: Array.prototype.indexOf.call(this.textBody.children, line), + offset: offset, + }; + }; + + TextEditor.prototype.getLine = function (lineNr) { + return this.textBody.children[lineNr]; + }; + + TextEditor.prototype.getLineText = function (line) { + return line.textContent; + }; + + TextEditor.prototype.setLineText = function (line, text) { + line.textContent = text; + if (text === "") { + line.appendChild(document.createElement("br")); + } + }; + + // Counts a line break as one character. + TextEditor.prototype.movePosition = function (position, delta) { + var remainingDelta = Math.abs(delta); + var sign = delta >= 0 ? 1 : -1; + + var lineNr = position.line; + var offset = position.offset; + var line = this.getLine(lineNr); + var lineLength = this.getLineText(line).length; + + while (true) { + var remaining = sign > 0 ? lineLength - offset : offset; + var step = Math.min(remaining, remainingDelta); + offset += sign * step; + remainingDelta -= step; + if (remainingDelta === 0) { + break; + } + + line = sign > 0 ? line.nextElementSibling : line.previousElementSibling; + if (line === null) { + break; + } + lineLength = this.getLineText(line).length; + lineNr += sign; + offset = sign > 0 ? 0 : lineLength; + remainingDelta -= 1; + } + + return { line: lineNr, offset: offset }; + }; + + TextEditor.prototype.getText = function (from, to) { + var result = ""; + for (var lineNr = from.line; lineNr <= to.line; ++lineNr) { + if (lineNr > from.line) { + result += "\n"; + } + var lineText = this.getLineText(this.getLine(lineNr)); + if (from.line === to.line) { + result += lineText.slice(from.offset, to.offset); + } else if (lineNr === from.line) { + result += lineText.slice(from.offset); + } else if (lineNr === to.line) { + result += lineText.slice(0, to.offset); + } else { + result += lineText; + } + } + return result; + }; + + TextEditor.prototype.insertText = function (position, text) { + var textLines = text.split("\n"); + + var line = this.getLine(position.line); + var originalText = this.getLineText(line); + + if (textLines.length === 1) { + this.setLineText( + line, + originalText.slice(0, position.offset) + + textLines[0] + + originalText.slice(position.offset) + ); + return { + line: position.line, + offset: position.offset + textLines[0].length, + }; + } + + for (var i = 0; i < textLines.length; ++i) { + if (i > 0) { + this.textBody.insertBefore( + document.createElement("div"), + line.nextElementSibling + ); + line = line.nextElementSibling; + + this.textNr.appendChild(document.createElement("div")); + // the line is already in, so the count is the number the cell gets + this.textNr.lastChild.textContent = String(this.textBody.children.length); + } + + if (i === 0) { + this.setLineText(line, originalText.slice(0, position.offset) + textLines[i]); + } else if (i === textLines.length - 1) { + this.setLineText(line, textLines[i] + originalText.slice(position.offset)); + } else { + this.setLineText(line, textLines[i]); + } + } + + return { + line: position.line + textLines.length - 1, + offset: textLines[textLines.length - 1].length, + }; + }; + + TextEditor.prototype.removeText = function (from, to) { + var firstLine = this.getLine(from.line); + var lastLine = this.getLine(to.line); + + this.setLineText( + firstLine, + this.getLineText(firstLine).slice(0, from.offset) + + this.getLineText(lastLine).slice(to.offset) + ); + + for (var lineNr = from.line + 1; lineNr <= to.line; ++lineNr) { + this.textBody.removeChild(firstLine.nextElementSibling); + this.textNr.removeChild(this.textNr.lastChild); + } + }; + + TextEditor.prototype.placeCursorAt = function (position) { + var line = this.getLine(position.line); + var range = document.createRange(); + range.setStart(line.firstChild, position.offset); + range.setEnd(line.firstChild, position.offset); + range.collapse(true); + + var selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + }; + + TextEditor.prototype.doChange = function (change) { + if (change.type === "insertText") { + this.insertText(change.position, change.text); + } else if (change.type === "removeText") { + this.removeText( + change.position, + this.movePosition(change.position, change.text.length) + ); + } + }; + + TextEditor.prototype.invertChange = function (change) { + return { + type: change.type === "insertText" ? "removeText" : "insertText", + text: change.text, + position: change.position, + }; + }; + + TextEditor.prototype.pushChange = function (change) { + this.past.push(change); + this.future = []; + }; + + TextEditor.prototype.undo = function () { + if (this.past.length === 0) { + return; + } + var change = this.past.pop(); + this.future.push(change); + this.doChange(this.invertChange(change)); + }; + + TextEditor.prototype.redo = function () { + if (this.future.length === 0) { + return; + } + var change = this.future.pop(); + this.past.push(change); + this.doChange(change); + }; + + TextEditor.prototype.insertTextAction = function (text) { + var selection = window.getSelection(); + if (selection.rangeCount !== 1) { + console.log("Multiple selection ranges, not supported"); + return; + } + var range = selection.getRangeAt(0); + var position = this.getPosition(range.startContainer, range.startOffset); + + if ( + range.startContainer !== range.endContainer || + range.startOffset !== range.endOffset + ) { + this.removeTextAction("backward"); + } + + var newPosition = this.insertText(position, text); + this.pushChange({ type: "insertText", text: text, position: position }); + this.placeCursorAt(newPosition); + }; + + TextEditor.prototype.removeTextAction = function (mode) { + var selection = window.getSelection(); + if (selection.rangeCount !== 1) { + console.log("Multiple selection ranges, not supported"); + return; + } + var range = selection.getRangeAt(0); + var startPosition = this.getPosition(range.startContainer, range.startOffset); + var endPosition = this.getPosition(range.endContainer, range.endOffset); + var isSelected = + range.startContainer !== range.endContainer || + range.startOffset !== range.endOffset; + + var from = isSelected + ? startPosition + : mode === "forward" + ? startPosition + : this.movePosition(startPosition, -1); + var to = isSelected + ? endPosition + : mode === "forward" + ? this.movePosition(endPosition, 1) + : endPosition; + + if (from.line === to.line && from.offset === to.offset) { + console.log("No text to remove"); + return; + } + + var removedText = this.getText(from, to); + this.removeText(from, to); + this.pushChange({ + type: "removeText", + text: removedText, + position: from, + }); + this.placeCursorAt(from); + }; + + var textNr = document.querySelector(".odr-text-nr"); + var textBody = document.querySelector(".odr-text-body"); + if (textNr && textBody) { + new TextEditor(textNr, textBody); + } +})(); +)js"; + +void write_style(HtmlWriter &out, const char *css) { + out.write_header_style_begin(); + out.out() << css; + out.write_header_style_end(); +} + +void write_script(HtmlWriter &out, const char *js) { + out.write_script_begin(); + out.out() << js; + out.write_script_end(); +} + +} // namespace + +} // namespace odr::internal::html + +namespace odr::internal { + +void html::write_document_style(HtmlWriter &out) { + write_style(out, document_css); +} + +void html::write_spreadsheet_style(HtmlWriter &out) { + write_style(out, spreadsheet_css); +} + +void html::write_text_style(HtmlWriter &out) { write_style(out, text_css); } + +void html::write_document_script(HtmlWriter &out) { + write_script(out, document_js); +} + +void html::write_text_script(HtmlWriter &out) { write_script(out, text_js); } + +} // namespace odr::internal diff --git a/src/odr/internal/html/frontend.hpp b/src/odr/internal/html/frontend.hpp new file mode 100644 index 00000000..e6427c69 --- /dev/null +++ b/src/odr/internal/html/frontend.hpp @@ -0,0 +1,19 @@ +#pragma once + +namespace odr::internal::html { + +class HtmlWriter; + +/// Each of these writes one complete `