diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77f21d4..0877111 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,8 @@ jobs: distribution: temurin java-version: ${{ matrix.java-version }} + - uses: android-actions/setup-android@v3 + - uses: gradle/actions/setup-gradle@v4 - name: Build library @@ -33,6 +35,10 @@ jobs: - name: Unit tests run: ./gradlew :compose2pdf:test + - name: iOS simulator tests (macOS) + if: runner.os == 'macOS' && matrix.java-version == 17 + run: ./gradlew :compose2pdf:iosSimulatorArm64Test + - name: Fidelity tests (Linux) if: runner.os == 'Linux' && matrix.java-version == 17 run: xvfb-run ./gradlew :fidelity-test:test @@ -50,5 +56,5 @@ jobs: if-no-files-found: ignore - name: Smoke test publishToMavenLocal - if: matrix.os == 'ubuntu-latest' && matrix.java-version == 17 + if: matrix.os == 'macos-latest' && matrix.java-version == 17 run: ./gradlew :compose2pdf:publishToMavenLocal diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d29a9d0..678dc87 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,6 +47,8 @@ jobs: distribution: temurin java-version: 17 + - uses: android-actions/setup-android@v3 + - uses: gradle/actions/setup-gradle@v4 - name: Build library @@ -73,7 +75,7 @@ jobs: publish: needs: test - runs-on: ubuntu-latest + runs-on: macos-latest steps: - uses: actions/checkout@v4 with: @@ -84,6 +86,8 @@ jobs: distribution: temurin java-version: 17 + - uses: android-actions/setup-android@v3 + - uses: gradle/actions/setup-gradle@v4 - name: Publish to Maven Central diff --git a/.github/workflows/snapshot.yml b/.github/workflows/snapshot.yml index d2fedf8..77cc33d 100644 --- a/.github/workflows/snapshot.yml +++ b/.github/workflows/snapshot.yml @@ -19,7 +19,7 @@ concurrency: jobs: snapshot: - runs-on: ubuntu-latest + runs-on: macos-latest steps: - uses: actions/checkout@v4 with: @@ -30,6 +30,8 @@ jobs: distribution: temurin java-version: 17 + - uses: android-actions/setup-android@v3 + - uses: gradle/actions/setup-gradle@v4 - name: Verify SNAPSHOT version @@ -42,7 +44,7 @@ jobs: fi - name: Build and test - run: xvfb-run ./gradlew :compose2pdf:build :compose2pdf:test :fidelity-test:test + run: ./gradlew :compose2pdf:build :compose2pdf:test :fidelity-test:test - name: Publish snapshot env: diff --git a/CLAUDE.md b/CLAUDE.md index 3336815..0601e39 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,36 +2,54 @@ ## Project Overview -**compose2pdf** is a Kotlin JVM library that renders Compose Desktop content to PDF. +**compose2pdf** is a Kotlin Multiplatform library that renders Compose content to PDF on JVM/Desktop, Android, and iOS. ## Module Map ``` -├── compose2pdf/ # Library: public API + SVG→PDF converter + font resolver -├── examples/ # Runnable examples (not published) -├── fidelity-test/ # Visual regression tests (not published) +├── compose2pdf/ # Library: multiplatform (commonMain, jvmMain, androidMain, iosMain) +├── test-fixtures/ # Multiplatform test utilities shared across JVM, Android, iOS +├── examples/ # Runnable JVM examples (not published) +├── fidelity-test/ # Visual regression tests, JVM only (not published) └── docs/ # Jekyll docs site (GitHub Pages, just-the-docs theme) ``` ## Tech Stack -- **Kotlin** 2.3.20, JVM target only -- **Compose Multiplatform** 1.10.3 (Desktop) -- **Apache PDFBox** 3.0.7 (SVG→PDF conversion, image embedding, font subsetting) +- **Kotlin** 2.3.20 — targets: JVM, Android (minSdk 24), iOS (arm64, x64, simulatorArm64) +- **Compose Multiplatform** 1.10.3 (Desktop, Android, iOS) +- **Android Gradle Plugin** 8.9.3 +- **Apache PDFBox** 3.0.7 — JVM only (SVG→PDF conversion, image embedding, font subsetting) +- **android.graphics.pdf.PdfDocument** — Android native PDF (zero external dependencies) +- **Core Graphics (CGPDFContext)** — iOS native PDF rendering - **Gradle** 8.14 — versions centralized in `gradle/libs.versions.toml` ## Build Commands ```bash -./gradlew :compose2pdf:build # Build library -./gradlew :compose2pdf:test # Run unit tests -./gradlew :fidelity-test:test # Run fidelity tests (vector + raster PDF) +# JVM +./gradlew :compose2pdf:build # Build all KMP targets +./gradlew :compose2pdf:test # Run JVM unit tests +./gradlew :compose2pdf:compileKotlin # Quick compile check (no tests) +./gradlew :fidelity-test:test # Run fidelity tests (vector + raster PDF) ./gradlew :fidelity-test:test --rerun-tasks # Force re-run (bypass Gradle cache) -./gradlew :compose2pdf:publishToMavenLocal # Publish to ~/.m2 -./gradlew :compose2pdf:compileKotlin # Quick compile check (no tests) -./gradlew :examples:run # Run examples, output to examples/build/output/ +./gradlew :examples:run # Run JVM examples, output to examples/build/output/ + +# Android +./gradlew :compose2pdf:pixel2api30atdDebugAndroidTest # Run Android instrumented tests (managed device) + +# iOS +./gradlew :compose2pdf:iosSimulatorArm64Test # Run iOS simulator tests + +# Publishing +./gradlew :compose2pdf:publishToMavenLocal # Publish all targets to ~/.m2 + +# Test fixtures +./gradlew :test-fixtures:build # Build shared test utilities + +# Docs open fidelity-test/build/reports/fidelity/index.html # View fidelity report (macOS) -cd docs && bundle exec jekyll serve # Preview docs site locally (http://localhost:4000) +cd docs && bundle exec jekyll serve # Preview docs site locally (http://localhost:4000) ``` ## Fidelity Tests @@ -43,36 +61,95 @@ Visual regression suite comparing Compose reference renders against rasterized P ## CI -GitHub Actions (`.github/workflows/ci.yml`): build + unit tests + fidelity tests on ubuntu/macos, JDK 17. Uses `xvfb-run` for headless Compose on Linux. +GitHub Actions (`.github/workflows/ci.yml`): build + unit tests + fidelity tests + iOS simulator tests on ubuntu/macos, JDK 17. Uses `xvfb-run` for headless Compose on Linux. Android SDK setup via `android-actions/setup-android@v3`. Compose Multiplatform compatibility matrix (`.github/workflows/compatibility.yml`): tests against the 3 most recent CMP versions (defined in `.github/compose-versions.json`). Auto-updated weekly by `.github/workflows/update-compose-versions.yml`. ## Public API +### JVM (full-featured) + +```kotlin +renderToPdf(config, density, mode, defaultFontFamily, pagination) { content } → ByteArray +renderToPdf(outputStream, config, density, mode, defaultFontFamily, pagination) { content } +renderToPdf(pages, config, density, mode, defaultFontFamily) { pageIndex → content } → ByteArray +renderToPdf(outputStream, pages, config, density, mode, defaultFontFamily) { pageIndex → content } +``` + +### Android (suspend, requires Context) + +```kotlin +suspend renderToPdf(context, config, density, defaultFontFamily, pagination) { content } → ByteArray +suspend renderToPdf(context, outputStream, config, density, defaultFontFamily, pagination) { content } +``` + +### iOS (synchronous, ByteArray only) + +```kotlin +renderToPdf(config, density, defaultFontFamily, pagination) { content } → ByteArray +``` + +### Common (all platforms) + ```kotlin -renderToPdf(config, density, mode, defaultFontFamily, pagination) { content } → ByteArray // auto-paginates by default -renderToPdf(outputStream, config, density, mode, defaultFontFamily, pagination) { content } // streaming variant -renderToPdf(pages, config, density, mode, defaultFontFamily) { pageIndex → content } → ByteArray // manual pages -renderToPdf(outputStream, pages, config, density, mode, defaultFontFamily) { pageIndex → content } // streaming variant PdfLink(href) { content } PdfRoundedCornerShape(topStart, topEnd, bottomEnd, bottomStart) Shape.asPdfSafe() ``` -Types: `PdfPageConfig` (A4/A4WithMargins/Letter/LetterWithMargins/A3/A3WithMargins + `landscape()`), `PdfMargins` (None/Narrow/Normal + `symmetric()`), `PdfPagination` (AUTO/SINGLE_PAGE), `Density`, `RenderMode` (VECTOR/RASTER), `InterFontFamily`, `Compose2PdfException`. +### Platform availability + +| Feature | JVM | Android | iOS | +|:--------|:---:|:-------:|:---:| +| `RenderMode` (VECTOR/RASTER) | Yes | No (always vector) | No (always vector) | +| `InterFontFamily` (bundled Inter) | Yes | No (system fonts) | No (system fonts) | +| `OutputStream` streaming | Yes | Yes | No | +| Multi-page manual API | Yes | No | No | +| `PdfLink` annotations | Yes | No | No | +| Auto-pagination | Yes | Yes | Yes | + +Types: `PdfPageConfig`, `PdfMargins`, `PdfPagination`, `Density`, `RenderMode` (JVM), `InterFontFamily` (JVM), `Compose2PdfException`. ## Key Files -- `compose2pdf/src/main/kotlin/.../Compose2Pdf.kt` — Public API entry points -- `compose2pdf/src/main/kotlin/.../internal/PdfRenderer.kt` — Rendering orchestrator (vector, raster, auto-pagination) -- `compose2pdf/src/main/kotlin/.../internal/ComposeToSvg.kt` — Compose → SVG + measurement -- `compose2pdf/src/main/kotlin/.../internal/SvgToPdfConverter.kt` — SVG → PDF pages -- `compose2pdf/src/main/kotlin/.../internal/PaginatedColumn.kt` — Smart page-break layout -- `compose2pdf/src/main/kotlin/.../internal/FontResolver.kt` — Font resolution + subsetting -- `fidelity-test/src/test/.../FidelityFixtures.kt` — All fidelity test composables +### commonMain +- `compose2pdf/src/commonMain/.../PdfPageConfig.kt` — Page size and margin configuration +- `compose2pdf/src/commonMain/.../PdfMargins.kt` — Margin presets +- `compose2pdf/src/commonMain/.../PdfLink.kt` — Link annotation composable + collector +- `compose2pdf/src/commonMain/.../internal/PaginatedColumn.kt` — Smart page-break layout +- `compose2pdf/src/commonMain/.../internal/PageLayout.kt` — Page layout utilities + +### jvmMain +- `compose2pdf/src/jvmMain/.../Compose2Pdf.kt` — JVM public API entry points +- `compose2pdf/src/jvmMain/.../PdfFonts.kt` — InterFontFamily (bundled Inter fonts) +- `compose2pdf/src/jvmMain/.../internal/PdfRenderer.kt` — Rendering orchestrator (vector, raster, auto-pagination) +- `compose2pdf/src/jvmMain/.../internal/ComposeToSvg.kt` — Compose → SVG + measurement +- `compose2pdf/src/jvmMain/.../internal/SvgToPdfConverter.kt` — SVG → PDF pages via PDFBox +- `compose2pdf/src/jvmMain/.../internal/FontResolver.kt` — Font resolution + subsetting + +### androidMain +- `compose2pdf/src/androidMain/.../Compose2Pdf.android.kt` — Android public API (suspend, Context) +- `compose2pdf/src/androidMain/.../internal/AndroidPdfRenderer.kt` — Rendering via android.graphics.pdf.PdfDocument +- `compose2pdf/src/androidMain/.../internal/OffScreenComposeRenderer.kt` — Headless Compose rendering via virtual display + +### iosMain +- `compose2pdf/src/iosMain/.../Compose2Pdf.ios.kt` — iOS public API +- `compose2pdf/src/iosMain/.../internal/IosPdfRenderer.kt` — Rendering orchestrator +- `compose2pdf/src/iosMain/.../internal/ComposeToSvg.kt` — Compose → SVG via Skia (iOS) +- `compose2pdf/src/iosMain/.../internal/CoreGraphicsPdfConverter.kt` — SVG → PDF via CGPDFContext +- `compose2pdf/src/iosMain/.../internal/SvgDocument.kt` — SVG parsing via NSXMLParser +- `compose2pdf/src/iosMain/.../internal/CoreGraphicsPathParser.kt` — SVG path → Core Graphics paths + +### Tests +- `fidelity-test/src/test/.../FidelityFixtures.kt` — All fidelity test composables (JVM) +- `compose2pdf/src/jvmTest/` — JVM unit tests +- `compose2pdf/src/androidInstrumentedTest/` — Android instrumented tests +- `compose2pdf/src/iosSimulatorArm64Test/` — iOS simulator tests ## Architecture +### JVM Pipeline + ``` Compose content → ComposeToSvg.render() → SVG string → SvgToPdfConverter (orchestrator) @@ -92,12 +169,34 @@ Auto-pagination: PaginatedColumn (smart page breaks) → SvgToPdfConverter.addAutoPages() (clip + offset per page) ``` +### Android Pipeline + +``` +Compose content → OffScreenComposeRenderer (headless virtual display) + → View.draw() → android.graphics.pdf.PdfDocument Canvas (Skia-backed) + → PDF bytes + +Always vector output. PdfDocument's Canvas is backed by Skia, producing +resolution-independent paths and selectable text. +``` + +### iOS Pipeline + +``` +Compose content → CanvasLayersComposeScene → Skia SVGCanvas → SVG string + → NSXMLParser → SvgElement tree + → CoreGraphicsPdfConverter (CGPDFContext) + ├── CoreGraphicsPathParser (SVG path → CGPath) + └── CTFontDrawGlyphs (per-glyph text rendering) + → PDF bytes (NSMutableData) +``` + ## Gotchas ### Docs Site - **URL structure** — standalone pages (`getting-started.md`) produce `/page.html` URLs; directory index pages (`usage/index.md`) produce `/directory/` URLs. Don't use trailing slashes for standalone pages. -### Rendering +### Rendering (JVM) - **`@InternalComposeUiApi` opt-in required** — `CanvasLayersComposeScene` is internal Compose API - **Variable fonts excluded** — `FontResolver.isVariableFont()` skips fonts with `fvar` table - **SVGCanvas bezier approximation** — non-uniform rounded rects become complex bezier paths; use `PdfRoundedCornerShape` @@ -105,6 +204,20 @@ Auto-pagination: PaginatedColumn (smart page breaks) - **`Compose2PdfException` wraps rendering errors** — `IllegalArgumentException` (precondition failures) passes through unwrapped - **OutputStream overloads don't close the stream** — caller owns the stream lifecycle; `PDDocument.use { it.save(outputStream) }` is called internally +### Rendering (Android) +- **`renderToPdf` is suspend-only** — requires main thread for off-screen Compose rendering via virtual display +- **Requires `Context` parameter** — any Context works, not just Activity +- **No `PdfLink` support** — `android.graphics.pdf.PdfDocument` has no annotation API; `PdfLink` is a no-op +- **No `RenderMode` parameter** — always produces vector output via PdfDocument's Skia-backed Canvas +- **No `InterFontFamily`** — uses Android's system font stack + +### Rendering (iOS) +- **Uses `NSXMLParser` for SVG parsing** — not javax.xml (that's JVM-only) +- **`CTFontDrawGlyphs` for per-glyph text rendering** — fixes spacing issues with Core Text +- **ByteArray return only** — no `OutputStream` streaming overload +- **No multi-page manual API** — only auto-pagination +- **No `PdfLink` annotation support** — Core Graphics PDF context doesn't write link annotations + ### Auto-pagination - **Measures in tall scene** — `PdfRenderer` uses a 200K px max scene height for measurement; Compose `Constraints` limit is ~262K px - **Falls back for single-page content** — If measured height ≤ page height or ≥ max height (fillMaxHeight detected), falls back to original single-page rendering path for identical output @@ -113,15 +226,29 @@ Auto-pagination: PaginatedColumn (smart page breaks) ### Testing - **Fidelity tests assume identical render path** — Changing `renderToPdf` default behavior (e.g., wrapping content in extra layout layers or using a taller scene) can break fidelity comparisons; single-page content must fall back to the original render path - **Compose `Placeable` is not fakeable** — `width`/`height` are final; test layout logic with raw `List` heights instead of mock `Placeable` objects +- **Android tests use `androidInstrumentedTest`** — requires AndroidX test runner and a managed device or emulator +- **iOS tests use `iosSimulatorArm64Test`** — runs on macOS only (requires Xcode simulator) ## Code Conventions - Package: `com.chrisjenx.compose2pdf` - Internal implementation in `com.chrisjenx.compose2pdf.internal` - `internal` visibility by default for implementation classes -- Public API: `renderToPdf()`, `PdfLink()`, `PdfPageConfig`, `PdfMargins`, `PdfPagination`, `RenderMode`, `Density`, `InterFontFamily`, `PdfRoundedCornerShape`, `Shape.asPdfSafe()`, `Compose2PdfException` — everything else is `internal` +- Platform-specific implementations use file-level platform suffixes (e.g., `Compose2Pdf.android.kt`, `Compose2Pdf.ios.kt`) +- Public API (JVM): `renderToPdf()`, `PdfLink()`, `PdfPageConfig`, `PdfMargins`, `PdfPagination`, `RenderMode`, `Density`, `InterFontFamily`, `PdfRoundedCornerShape`, `Shape.asPdfSafe()`, `Compose2PdfException` — everything else is `internal` +- Public API (Android/iOS): `renderToPdf()`, `PdfLink()`, `PdfPageConfig`, `PdfMargins`, `PdfPagination`, `Density`, `PdfRoundedCornerShape`, `Shape.asPdfSafe()`, `Compose2PdfException` - Tests use `kotlin-test` ## Publishing -Maven Central via Sonatype (`s01.oss.sonatype.org`), group ID `com.chrisjenx`. Requires `ossrhUsername`, `ossrhPassword`, and `signing.keyId` in `local.properties` or environment variables. +Maven Central via Vanniktech Maven Publish plugin (`publishToMavenCentral()` + `signAllPublications()`), group ID `com.chrisjenx`. + +KMP produces these artifacts: +- `com.chrisjenx:compose2pdf` — root metadata module (Gradle Module Metadata) +- `com.chrisjenx:compose2pdf-jvm` — JVM JAR +- `com.chrisjenx:compose2pdf-android` — Android AAR (release variant via `publishLibraryVariants("release")`) +- `com.chrisjenx:compose2pdf-iosarm64` / `iosx64` / `iossimulatorarm64` — iOS klibs + +Existing consumers using `implementation("com.chrisjenx:compose2pdf:X.X.X")` continue to work via Gradle Module Metadata (Gradle 6.0+ automatically resolves to the correct platform artifact). + +Requires `mavenCentralUsername`, `mavenCentralPassword`, and `signingInMemoryKey*` in environment variables or GitHub secrets. Release and snapshot workflows run on `macos-latest` to build all targets (iOS requires macOS). diff --git a/README.md b/README.md index 90329d9..9757691 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Maven Central](https://img.shields.io/maven-central/v/com.chrisjenx/compose2pdf)](https://central.sonatype.com/artifact/com.chrisjenx/compose2pdf) [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0) -A **Kotlin JVM library** for rendering [Compose Desktop](https://www.jetbrains.com/compose-multiplatform/) content directly to PDF. Generate production-quality PDF documents with vector text, embedded fonts, auto-pagination, and server-side streaming support. +A **Kotlin Multiplatform library** for rendering [Compose Multiplatform](https://www.jetbrains.com/compose-multiplatform/) content directly to PDF. Generate production-quality PDF documents on **JVM/Desktop**, **Android**, and **iOS** with vector text, embedded fonts, and auto-pagination. ```kotlin val pdfBytes = renderToPdf { @@ -24,9 +24,9 @@ File("hello.pdf").writeBytes(pdfBytes) ## Features - **Vector PDF output** — text is selectable, scales to any zoom level -- **Raster fallback** — pixel-perfect rendering as an embedded image -- **Font embedding** — bundled Inter fonts or system font resolution with automatic subsetting -- **Link annotations** — clickable URLs in the PDF via `PdfLink` +- **Raster fallback** — pixel-perfect rendering as an embedded image (JVM) +- **Font embedding** — bundled Inter fonts (JVM) or system font resolution with automatic subsetting +- **Link annotations** — clickable URLs in the PDF via `PdfLink` (JVM) - **Auto-pagination** — content automatically flows across pages; elements are kept together at page boundaries - **Multi-page** — render multiple pages in a single PDF (manual or automatic) - **Page presets** — A4, Letter, A3 with configurable margins and landscape support @@ -35,13 +35,40 @@ File("hello.pdf").writeBytes(pdfBytes) ## Installation ```kotlin -// build.gradle.kts +// build.gradle.kts (Kotlin Multiplatform) +kotlin { + sourceSets { + commonMain.dependencies { + implementation("com.chrisjenx:compose2pdf:1.0.0") + } + } +} + +// Or for JVM/Android-only projects: dependencies { implementation("com.chrisjenx:compose2pdf:1.0.0") } ``` -Requires **JDK 17+** and **Compose Desktop** (Compose Multiplatform 1.9+). +## Platform support + +| Platform | Requirements | Status | +|:---------|:-------------|:-------| +| **JVM/Desktop** (macOS, Linux, Windows) | JDK 17+, Compose Multiplatform 1.9+ | Full support | +| **Android** | minSdk 24, Compose Multiplatform 1.9+ | Full support | +| **iOS** (arm64, x64, simulatorArm64) | Compose Multiplatform 1.9+ | Full support | + +### Platform feature matrix + +| Feature | JVM | Android | iOS | +|:--------|:---:|:-------:|:---:| +| Vector output | VECTOR / RASTER modes | Always vector | Always vector | +| Auto-pagination | Yes | Yes | Yes | +| Multi-page (manual) | Yes | -- | -- | +| OutputStream streaming | Yes | Yes | -- | +| `PdfLink` annotations | Yes | -- | -- | +| Bundled Inter font | Yes | -- (system fonts) | -- (system fonts) | +| `suspend` API | No | Yes (required) | No | ## Compatibility @@ -55,19 +82,9 @@ Tested weekly against the 3 most recent Compose Multiplatform releases: | **1.10.3** | 2.3.20 | CI tested (current) | | 1.9.3 | 2.3.20 | CI tested | -**Platform support:** - -| | JDK 17+ | JDK 21+ | -|:---|:-------:|:-------:| -| **macOS** (arm64, x64) | Supported | Supported | -| **Linux** (x64) | Supported | Supported | -| **Windows** (x64) | Supported | Supported | - -> Compose Desktop is JVM-only. Android and iOS are not supported. - ## Quick start -### Single page +### JVM ```kotlin val pdf = renderToPdf( @@ -81,6 +98,31 @@ val pdf = renderToPdf( } ``` +### Android + +```kotlin +val pdfBytes = renderToPdf( + context = applicationContext, + config = PdfPageConfig.A4WithMargins, +) { + Column(Modifier.fillMaxSize().padding(24.dp)) { + Text("Hello from Android!") + } +} +``` + +### iOS + +```kotlin +val pdfBytes = renderToPdf( + config = PdfPageConfig.A4WithMargins, +) { + Column(Modifier.fillMaxSize().padding(24.dp)) { + Text("Hello from iOS!") + } +} +``` + ### Auto-pagination (default) Content automatically flows across pages. Direct children are kept together — if a child would straddle a page boundary, it's pushed to the next page. @@ -93,7 +135,7 @@ val pdf = renderToPdf(config = PdfPageConfig.A4WithMargins) { } ``` -### Manual multi-page +### Manual multi-page (JVM) For full control over what goes on each page: @@ -105,7 +147,7 @@ val pdf = renderToPdf(pages = 3) { pageIndex -> } ``` -### Links +### Links (JVM) ```kotlin PdfLink(href = "https://example.com") { @@ -115,12 +157,11 @@ PdfLink(href = "https://example.com") { ## How it works -compose2pdf renders Compose content through a **Skia SVGCanvas → Apache PDFBox** pipeline: +Each platform uses a native PDF pipeline: -1. Your `@Composable` content is rendered by Compose Desktop's layout engine -2. Skia's SVGCanvas captures the draw calls as SVG -3. compose2pdf converts the SVG to PDF vector commands via PDFBox -4. Fonts are resolved, subsetted, and embedded; link annotations are mapped to PDF coordinates +- **JVM**: Compose → Skia SVGCanvas → SVG → Apache PDFBox → vector PDF with embedded fonts +- **Android**: Compose → off-screen virtual display → `android.graphics.pdf.PdfDocument` Canvas → vector PDF +- **iOS**: Compose → Skia SVGCanvas → SVG → Core Graphics (`CGPDFContext`) → vector PDF > **Want native PDF output from Skia?** The [Skiko PR #775](https://github.com/JetBrains/skiko/pull/775) proposes adding a direct PDF backend to Skia/Skiko, which would eliminate the SVG intermediary entirely — producing smaller files, faster rendering, and full gradient/effect support in vector mode. If this matters to you, upvote the PR! diff --git a/build.gradle.kts b/build.gradle.kts index 5f43c37..d82a4a9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,7 @@ plugins { alias(libs.plugins.kotlin.jvm) apply false + alias(libs.plugins.kotlin.multiplatform) apply false + alias(libs.plugins.android.library) apply false alias(libs.plugins.compose) apply false alias(libs.plugins.compose.compiler) apply false alias(libs.plugins.maven.publish) apply false diff --git a/compose2pdf/build.gradle.kts b/compose2pdf/build.gradle.kts index 7d62353..fd7bb21 100644 --- a/compose2pdf/build.gradle.kts +++ b/compose2pdf/build.gradle.kts @@ -1,26 +1,115 @@ plugins { - alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.android.library) alias(libs.plugins.compose) alias(libs.plugins.compose.compiler) alias(libs.plugins.maven.publish) } -dependencies { - implementation(compose.desktop.common) - implementation(libs.pdfbox) - - testImplementation(compose.desktop.currentOs) - testImplementation(libs.kotlin.test) - testImplementation(libs.kotlinx.coroutines.test) -} - kotlin { jvmToolchain(17) + + jvm() + + androidTarget { + publishLibraryVariants("release") + @Suppress("OPT_IN_USAGE") + instrumentedTestVariant.sourceSetTree.set(org.jetbrains.kotlin.gradle.plugin.KotlinSourceSetTree.test) + } + + listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64(), + ).forEach { target -> + target.binaries.framework { + baseName = "compose2pdf" + isStatic = true + } + } + + sourceSets { + commonMain.dependencies { + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.ui) + } + + jvmMain.dependencies { + implementation(compose.desktop.common) + implementation(libs.pdfbox) + } + + jvmTest.dependencies { + implementation(compose.desktop.currentOs) + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + } + + androidMain.dependencies { + // Uses android.graphics.pdf.PdfDocument (platform API, no external dependency) + } + + val androidInstrumentedTest by getting { + dependencies { + implementation(project(":test-fixtures")) + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + implementation("androidx.test:runner:1.6.2") + implementation("androidx.test:rules:1.6.1") + implementation("androidx.test.ext:junit:1.2.1") + implementation("androidx.test.services:test-services:1.5.0") + implementation(compose.material3) + } + } + + iosMain.dependencies { + } + + val iosSimulatorArm64Test by getting { + dependencies { + implementation(project(":test-fixtures")) + implementation(libs.kotlin.test) + implementation(compose.material3) + } + } + } + compilerOptions { freeCompilerArgs.add("-opt-in=androidx.compose.ui.InternalComposeUiApi") } } +android { + namespace = "com.chrisjenx.compose2pdf" + compileSdk = 35 + defaultConfig { + minSdk = 24 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunnerArguments["useTestStorageService"] = "true" + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + @Suppress("UnstableApiUsage") + testOptions { + managedDevices { + localDevices { + create("pixel2api30atd") { + device = "Pixel 2" + apiLevel = 30 + systemImageSource = "aosp-atd" + } + } + } + } +} + +dependencies { + androidTestUtil("androidx.test.services:test-services:1.5.0") +} + mavenPublishing { publishToMavenCentral() signAllPublications() @@ -28,7 +117,7 @@ mavenPublishing { pom { name.set("compose2pdf") description.set( - "Kotlin JVM library for rendering Compose Desktop content to production-quality PDFs " + + "Kotlin Multiplatform library for rendering Compose content to production-quality PDFs " + "with vector text, embedded fonts, auto-pagination, and server-side streaming support." ) url.set("https://github.com/chrisjenx/compose2pdf") diff --git a/compose2pdf/src/androidInstrumentedTest/kotlin/com/chrisjenx/compose2pdf/AndroidFidelityFixtures.kt b/compose2pdf/src/androidInstrumentedTest/kotlin/com/chrisjenx/compose2pdf/AndroidFidelityFixtures.kt new file mode 100644 index 0000000..5e554fa --- /dev/null +++ b/compose2pdf/src/androidInstrumentedTest/kotlin/com/chrisjenx/compose2pdf/AndroidFidelityFixtures.kt @@ -0,0 +1,10 @@ +package com.chrisjenx.compose2pdf + +import com.chrisjenx.compose2pdf.fixtures.SharedFixture +import com.chrisjenx.compose2pdf.fixtures.sharedFixtures + +/** + * Android fidelity fixtures — delegates entirely to the shared test-fixtures module. + * This ensures JVM and Android render the exact same composable content. + */ +val androidFidelityFixtures: List = sharedFixtures diff --git a/compose2pdf/src/androidInstrumentedTest/kotlin/com/chrisjenx/compose2pdf/AndroidFidelityTest.kt b/compose2pdf/src/androidInstrumentedTest/kotlin/com/chrisjenx/compose2pdf/AndroidFidelityTest.kt new file mode 100644 index 0000000..392a4d9 --- /dev/null +++ b/compose2pdf/src/androidInstrumentedTest/kotlin/com/chrisjenx/compose2pdf/AndroidFidelityTest.kt @@ -0,0 +1,116 @@ +package com.chrisjenx.compose2pdf + +import android.graphics.Bitmap +import android.graphics.pdf.PdfRenderer as AndroidPdfRenderer +import android.os.ParcelFileDescriptor +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.services.storage.TestStorage +import kotlinx.coroutines.runBlocking +import java.io.File +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * Renders all shared fidelity fixtures on Android and saves PDFs via TestStorage + * for cross-platform comparison with the JVM fidelity report. + * + * PDF output naming convention: `{fixture-name}-android.pdf` + * These are extracted to: + * build/outputs/managed_device_android_test_additional_output/debug/{device}/ + */ +class AndroidFidelityTest { + + private val context get() = InstrumentationRegistry.getInstrumentation().targetContext + + @Test + fun renderAllFidelityFixtures() = runBlocking { + val results = mutableListOf() + + for (fixture in androidFidelityFixtures) { + try { + val bytes = renderToPdf( + context = context, + config = fixture.config, + ) { + fixture.content() + } + + // Validate PDF + assertTrue(bytes.size > 100, "${fixture.name}: PDF too small (${bytes.size} bytes)") + assertTrue( + bytes[0] == '%'.code.toByte() && bytes[1] == 'P'.code.toByte(), + "${fixture.name}: not a valid PDF", + ) + + // Verify we can open and read the PDF + val pageCount = countPdfPages(bytes) + assertTrue(pageCount >= 1, "${fixture.name}: PDF has no pages") + + // Verify rendered page has content (not blank) + val bitmap = renderFirstPageToBitmap(bytes) + assertTrue( + hasNonWhitePixels(bitmap), + "${fixture.name}: rendered page appears blank", + ) + + // Save via TestStorage for extraction + saveTestOutput("${fixture.name}-android.pdf", bytes) + results.add("PASS: ${fixture.name} (${bytes.size} bytes, $pageCount pages)") + } catch (e: Exception) { + results.add("FAIL: ${fixture.name} - ${e.message}") + } + } + + // Print summary + println("\n=== Android Fidelity Fixtures ===") + results.forEach { println(it) } + println("=================================\n") + + val failures = results.filter { it.startsWith("FAIL") } + assertTrue(failures.isEmpty(), "Fixture failures:\n${failures.joinToString("\n")}") + } + + // --- Helpers --- + + private fun countPdfPages(bytes: ByteArray): Int { + val file = writeTempFile(bytes) + return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY).use { fd -> + AndroidPdfRenderer(fd).use { it.pageCount } + } + } + + private fun renderFirstPageToBitmap(bytes: ByteArray): Bitmap { + val file = writeTempFile(bytes) + return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY).use { fd -> + AndroidPdfRenderer(fd).use { renderer -> + renderer.openPage(0).use { page -> + val bitmap = Bitmap.createBitmap(page.width, page.height, Bitmap.Config.ARGB_8888) + page.render(bitmap, null, null, AndroidPdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) + bitmap + } + } + } + } + + private fun hasNonWhitePixels(bitmap: Bitmap): Boolean { + val pixels = IntArray(bitmap.width * bitmap.height) + bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height) + val white = android.graphics.Color.WHITE + return pixels.any { it != white && it != 0 } + } + + private fun writeTempFile(bytes: ByteArray): File { + val file = File.createTempFile("compose2pdf_fidelity", ".pdf", context.cacheDir) + file.writeBytes(bytes) + file.deleteOnExit() + return file + } + + private fun saveTestOutput(name: String, bytes: ByteArray) { + try { + TestStorage().openOutputFile(name).use { it.write(bytes) } + } catch (_: Exception) { + // TestStorage may not be available in all environments + } + } +} diff --git a/compose2pdf/src/androidInstrumentedTest/kotlin/com/chrisjenx/compose2pdf/AndroidPdfRenderTest.kt b/compose2pdf/src/androidInstrumentedTest/kotlin/com/chrisjenx/compose2pdf/AndroidPdfRenderTest.kt new file mode 100644 index 0000000..b7672a6 --- /dev/null +++ b/compose2pdf/src/androidInstrumentedTest/kotlin/com/chrisjenx/compose2pdf/AndroidPdfRenderTest.kt @@ -0,0 +1,184 @@ +package com.chrisjenx.compose2pdf + +import android.graphics.Bitmap +import android.graphics.pdf.PdfRenderer as AndroidPdfRenderer +import android.os.ParcelFileDescriptor +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.services.storage.TestStorage +import kotlinx.coroutines.runBlocking +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class AndroidPdfRenderTest { + + private val context get() = InstrumentationRegistry.getInstrumentation().targetContext + + @Test + fun basicRender_producesValidPdf() = runBlocking { + val bytes = renderToPdf(context) { + Text("Hello from Compose2PDF on Android!", fontSize = 24.sp) + } + + assertValidPdf(bytes) + saveTestOutput("basic_render.pdf", bytes) + } + + @Test + fun renderWithMargins_producesValidPdf() = runBlocking { + val bytes = renderToPdf( + context, + config = PdfPageConfig.A4WithMargins, + ) { + Text("PDF with margins", fontSize = 20.sp) + } + + assertValidPdf(bytes) + saveTestOutput("margins_render.pdf", bytes) + } + + @Test + fun renderSinglePage_hasOnePage() = runBlocking { + val bytes = renderToPdf( + context, + pagination = PdfPagination.SINGLE_PAGE, + ) { + Text("Single page content", fontSize = 16.sp) + } + + assertValidPdf(bytes) + val pageCount = countPdfPages(bytes) + assertEquals(1, pageCount, "SINGLE_PAGE should produce exactly 1 page") + saveTestOutput("single_page.pdf", bytes) + } + + @Test + fun renderAutoPagination_tallContent_producesMultiplePages() = runBlocking { + val bytes = renderToPdf( + context, + config = PdfPageConfig.A4WithMargins, + pagination = PdfPagination.AUTO, + ) { + Column { + // Generate content taller than one A4 page (~700pt content height) + repeat(50) { i -> + Text( + text = "Line $i: The quick brown fox jumps over the lazy dog", + fontSize = 14.sp, + modifier = Modifier.padding(vertical = 4.dp), + ) + } + } + } + + assertValidPdf(bytes) + val pageCount = countPdfPages(bytes) + assertTrue(pageCount > 1, "Tall content should produce multiple pages, got $pageCount") + saveTestOutput("auto_pagination.pdf", bytes) + } + + @Test + fun renderWithComposableContent_producesNonEmptyPages() = runBlocking { + val bytes = renderToPdf(context) { + Column(modifier = Modifier.fillMaxWidth()) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(100.dp) + .background(Color.Blue), + ) + Spacer(modifier = Modifier.height(16.dp)) + Text("Below the blue box", fontSize = 18.sp) + } + } + + assertValidPdf(bytes) + + // Render first page to bitmap and verify it has non-white pixels + val bitmap = renderFirstPageToBitmap(bytes) + assertTrue(bitmap.width > 0 && bitmap.height > 0) + assertTrue(hasNonWhitePixels(bitmap), "Rendered page should have visible content") + saveTestOutput("composable_content.pdf", bytes) + } + + @Test + fun renderLetterConfig_producesValidPdf() = runBlocking { + val bytes = renderToPdf( + context, + config = PdfPageConfig.Letter, + ) { + Text("US Letter size PDF", fontSize = 20.sp) + } + + assertValidPdf(bytes) + saveTestOutput("letter_size.pdf", bytes) + } + + // --- Helpers --- + + private fun assertValidPdf(bytes: ByteArray) { + assertTrue(bytes.size > 100, "PDF should be non-trivial size, was ${bytes.size} bytes") + // PDF magic bytes: %PDF + assertEquals('%'.code.toByte(), bytes[0], "PDF should start with %PDF") + assertEquals('P'.code.toByte(), bytes[1]) + assertEquals('D'.code.toByte(), bytes[2]) + assertEquals('F'.code.toByte(), bytes[3]) + } + + private fun countPdfPages(bytes: ByteArray): Int { + val file = writeTempFile(bytes) + return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY).use { fd -> + AndroidPdfRenderer(fd).use { renderer -> + renderer.pageCount + } + } + } + + private fun renderFirstPageToBitmap(bytes: ByteArray): Bitmap { + val file = writeTempFile(bytes) + return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY).use { fd -> + AndroidPdfRenderer(fd).use { renderer -> + renderer.openPage(0).use { page -> + val bitmap = Bitmap.createBitmap(page.width, page.height, Bitmap.Config.ARGB_8888) + page.render(bitmap, null, null, AndroidPdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) + bitmap + } + } + } + } + + private fun hasNonWhitePixels(bitmap: Bitmap): Boolean { + val pixels = IntArray(bitmap.width * bitmap.height) + bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height) + val white = android.graphics.Color.WHITE + return pixels.any { it != white && it != 0 } + } + + private fun writeTempFile(bytes: ByteArray): File { + val file = File.createTempFile("compose2pdf_test", ".pdf", context.cacheDir) + file.writeBytes(bytes) + file.deleteOnExit() + return file + } + + private fun saveTestOutput(name: String, bytes: ByteArray) { + try { + TestStorage().openOutputFile(name).use { it.write(bytes) } + } catch (_: Exception) { + // TestStorage may not be available in all environments + } + } +} diff --git a/compose2pdf/src/androidMain/kotlin/com/chrisjenx/compose2pdf/Compose2Pdf.android.kt b/compose2pdf/src/androidMain/kotlin/com/chrisjenx/compose2pdf/Compose2Pdf.android.kt new file mode 100644 index 0000000..bb4a9a4 --- /dev/null +++ b/compose2pdf/src/androidMain/kotlin/com/chrisjenx/compose2pdf/Compose2Pdf.android.kt @@ -0,0 +1,81 @@ +package com.chrisjenx.compose2pdf + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.Density +import com.chrisjenx.compose2pdf.internal.AndroidPdfRenderer +import java.io.ByteArrayOutputStream +import java.io.OutputStream + +/** + * Renders Compose content to a PDF and writes it to [outputStream]. + * + * Uses Android's native [android.graphics.pdf.PdfDocument] (zero external dependencies). + * Compose content is rendered off-screen via a headless virtual display, then drawn + * directly onto PdfDocument's Skia-backed Canvas — producing vector PDF output + * (selectable text, resolution-independent paths). + * + * This is a suspend function because off-screen Compose rendering on Android requires + * the main thread and asynchronous composition. Call from any coroutine scope. + * + * Note: link annotations ([PdfLink]) are not supported on Android because + * [android.graphics.pdf.PdfDocument] does not expose annotation APIs. + * + * @param context Android context. Does not need to be an Activity — any Context works. + * @param outputStream The stream to write the PDF to. Not closed by this function. + * @param config Page size and margins. Defaults to A4. + * @param density Controls the pixel resolution used during Compose layout. 2f is a good default. + * @param defaultFontFamily The default text font family. Pass null to use system fonts. + * @param pagination Controls page splitting. Defaults to [PdfPagination.AUTO]. + * @param content The composable content to render. + * @throws Compose2PdfException if rendering fails. + */ +suspend fun renderToPdf( + context: Context, + outputStream: OutputStream, + config: PdfPageConfig = PdfPageConfig.A4, + density: Density = Density(2f), + defaultFontFamily: FontFamily? = null, + pagination: PdfPagination = PdfPagination.AUTO, + content: @Composable () -> Unit, +) { + try { + AndroidPdfRenderer.render(context, config, density, defaultFontFamily, pagination, content, outputStream) + } catch (e: Compose2PdfException) { + throw e + } catch (e: IllegalArgumentException) { + throw e + } catch (e: Exception) { + throw Compose2PdfException("Failed to render PDF: ${e.message}", e) + } +} + +/** + * Renders Compose content to a PDF as a ByteArray. + * + * Uses Android's native [android.graphics.pdf.PdfDocument] (zero external dependencies). + * Compose content is rendered off-screen via a headless virtual display, then drawn + * directly onto PdfDocument's Skia-backed Canvas — producing vector PDF output. + * + * @param context Android context. Does not need to be an Activity — any Context works. + * @param config Page size and margins. Defaults to A4. + * @param density Controls the pixel resolution used during Compose layout. 2f is a good default. + * @param defaultFontFamily The default text font family. Pass null to use system fonts. + * @param pagination Controls page splitting. Defaults to [PdfPagination.AUTO]. + * @param content The composable content to render. + * @return A valid PDF as a ByteArray. + * @throws Compose2PdfException if rendering fails. + */ +suspend fun renderToPdf( + context: Context, + config: PdfPageConfig = PdfPageConfig.A4, + density: Density = Density(2f), + defaultFontFamily: FontFamily? = null, + pagination: PdfPagination = PdfPagination.AUTO, + content: @Composable () -> Unit, +): ByteArray { + val baos = ByteArrayOutputStream() + renderToPdf(context, baos, config, density, defaultFontFamily, pagination, content) + return baos.toByteArray() +} diff --git a/compose2pdf/src/androidMain/kotlin/com/chrisjenx/compose2pdf/internal/AndroidPdfRenderer.kt b/compose2pdf/src/androidMain/kotlin/com/chrisjenx/compose2pdf/internal/AndroidPdfRenderer.kt new file mode 100644 index 0000000..99f0de3 --- /dev/null +++ b/compose2pdf/src/androidMain/kotlin/com/chrisjenx/compose2pdf/internal/AndroidPdfRenderer.kt @@ -0,0 +1,154 @@ +package com.chrisjenx.compose2pdf.internal + +import android.content.Context +import android.graphics.pdf.PdfDocument +import android.os.Handler +import android.os.Looper +import android.view.View +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.Density +import com.chrisjenx.compose2pdf.PdfPageConfig +import com.chrisjenx.compose2pdf.PdfPagination +import kotlinx.coroutines.suspendCancellableCoroutine +import java.io.ByteArrayOutputStream +import java.io.OutputStream +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.math.ceil + +/** + * Android-specific PDF renderer using [android.graphics.pdf.PdfDocument]. + * + * Uses [OffScreenComposeRenderer] to render Compose content in a headless virtual display, + * then draws the composed View directly onto [PdfDocument]'s Canvas. Because the PdfDocument + * Canvas is Skia-backed, Canvas draw operations (text, paths, shapes) produce vector PDF + * primitives — text is selectable and paths are resolution-independent. + * + * All rendering (Compose composition + View.draw) happens on the main thread to avoid + * RenderNode recording conflicts. + * + * Limitation: [android.graphics.pdf.PdfDocument] does not support link annotations, + * so [PdfLink] annotations are ignored on Android. + */ +internal object AndroidPdfRenderer { + + suspend fun render( + context: Context, + config: PdfPageConfig, + density: Density, + defaultFontFamily: FontFamily?, + pagination: PdfPagination, + content: @Composable () -> Unit, + outputStream: OutputStream, + ) { + val contentWidthPt = config.contentWidth.value + val contentHeightPt = config.contentHeight.value + val widthPx = (contentWidthPt * density.density).toInt() + + val renderer = OffScreenComposeRenderer(context) + try { + val composeView = renderer.render(widthPx, density, config, defaultFontFamily, content) + + // Build the PDF on the main thread. View.draw() must run on the same + // thread that owns the Compose RenderNode to avoid "recording in progress" errors. + val pdfBytes = buildPdfOnMainThread( + composeView, config, density, pagination, + ) + outputStream.write(pdfBytes) + } finally { + renderer.close() + } + } + + private suspend fun buildPdfOnMainThread( + composeView: View, + config: PdfPageConfig, + density: Density, + pagination: PdfPagination, + ): ByteArray = suspendCancellableCoroutine { continuation -> + Handler(Looper.getMainLooper()).post { + try { + val totalHeightPx = composeView.measuredHeight + val contentWidthPt = config.contentWidth.value + val contentHeightPt = config.contentHeight.value + val pageWidthPt = config.width.value.toInt() + val pageHeightPt = config.height.value.toInt() + val marginLeftPt = config.margins.left.value + val marginTopPt = config.margins.top.value + val scale = 1f / density.density + + val pdfDocument = PdfDocument() + try { + when (pagination) { + PdfPagination.SINGLE_PAGE -> { + addPage( + pdfDocument, composeView, 1, + pageWidthPt, pageHeightPt, + contentWidthPt, contentHeightPt, + marginLeftPt, marginTopPt, + scale, yOffsetPx = 0f, + ) + } + PdfPagination.AUTO -> { + val contentHeightPx = (contentHeightPt * density.density).toInt() + val numPages = if (totalHeightPx <= contentHeightPx) 1 + else ceil(totalHeightPx.toFloat() / contentHeightPx).toInt() + + for (i in 0 until numPages) { + addPage( + pdfDocument, composeView, i + 1, + pageWidthPt, pageHeightPt, + contentWidthPt, contentHeightPt, + marginLeftPt, marginTopPt, + scale, yOffsetPx = i * contentHeightPx.toFloat(), + ) + } + } + } + + val baos = ByteArrayOutputStream() + pdfDocument.writeTo(baos) + continuation.resume(baos.toByteArray()) + } finally { + pdfDocument.close() + } + } catch (e: Exception) { + continuation.resumeWithException(e) + } + } + } + + private fun addPage( + document: PdfDocument, + composeView: View, + pageNumber: Int, + pageWidthPt: Int, + pageHeightPt: Int, + contentWidthPt: Float, + contentHeightPt: Float, + marginLeftPt: Float, + marginTopPt: Float, + scale: Float, + yOffsetPx: Float, + ) { + val pageInfo = PdfDocument.PageInfo.Builder(pageWidthPt, pageHeightPt, pageNumber).create() + val page = document.startPage(pageInfo) + val canvas = page.canvas + + canvas.save() + canvas.clipRect( + marginLeftPt, + marginTopPt, + marginLeftPt + contentWidthPt, + marginTopPt + contentHeightPt, + ) + canvas.translate(marginLeftPt, marginTopPt) + canvas.scale(scale, scale) + canvas.translate(0f, -yOffsetPx) + composeView.draw(canvas) + canvas.restore() + + document.finishPage(page) + } +} diff --git a/compose2pdf/src/androidMain/kotlin/com/chrisjenx/compose2pdf/internal/OffScreenComposeRenderer.kt b/compose2pdf/src/androidMain/kotlin/com/chrisjenx/compose2pdf/internal/OffScreenComposeRenderer.kt new file mode 100644 index 0000000..a2c3d49 --- /dev/null +++ b/compose2pdf/src/androidMain/kotlin/com/chrisjenx/compose2pdf/internal/OffScreenComposeRenderer.kt @@ -0,0 +1,189 @@ +package com.chrisjenx.compose2pdf.internal + +import android.content.Context +import android.hardware.display.DisplayManager +import android.hardware.display.VirtualDisplay +import android.app.Presentation +import android.os.Handler +import android.os.Looper +import java.util.concurrent.CountDownLatch +import android.view.View +import android.view.ViewTreeObserver +import android.widget.FrameLayout +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.Density +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.setViewTreeLifecycleOwner +import androidx.savedstate.SavedStateRegistry +import androidx.savedstate.SavedStateRegistryController +import androidx.savedstate.SavedStateRegistryOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner +import com.chrisjenx.compose2pdf.LocalPdfPageConfig +import com.chrisjenx.compose2pdf.PdfPageConfig +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/** + * Manages off-screen Compose rendering on Android using a [VirtualDisplay] + [Presentation]. + * + * [ComposeView] requires window attachment to start composition. This renderer + * creates a headless virtual display (no Surface, no permissions required) and + * hosts a [Presentation] on it to provide a real Window context. This triggers + * composition without displaying anything on screen. + * + * After rendering, call [close] to release resources. + */ +internal class OffScreenComposeRenderer( + private val context: Context, +) : AutoCloseable { + + private var virtualDisplay: VirtualDisplay? = null + private var presentation: Presentation? = null + private var lifecycleOwner: OffScreenLifecycleOwner? = null + + /** + * Renders composable content off-screen and returns the measured [ComposeView]. + * + * All Compose/lifecycle operations are dispatched to the main looper via [Handler]. + * The returned view is composed, measured, and laid out — ready for [View.draw]. + */ + suspend fun render( + widthPx: Int, + density: Density, + config: PdfPageConfig, + defaultFontFamily: FontFamily?, + content: @Composable () -> Unit, + ): ComposeView = suspendCancellableCoroutine { continuation -> + val mainHandler = Handler(Looper.getMainLooper()) + + mainHandler.post { + try { + val owner = OffScreenLifecycleOwner() + owner.initialize() + lifecycleOwner = owner + + val dm = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager + val vd = dm.createVirtualDisplay( + "compose2pdf", + widthPx.coerceAtLeast(1), + 1, + context.resources.displayMetrics.densityDpi, + null, + DisplayManager.VIRTUAL_DISPLAY_FLAG_PRESENTATION, + ) + virtualDisplay = vd + + val pres = Presentation(context, vd.display) + presentation = pres + + // Set lifecycle owners on a FrameLayout wrapper. ComposeView traverses + // UP its parent tree to find these owners. + val wrapper = FrameLayout(context).apply { + setViewTreeLifecycleOwner(owner) + setViewTreeSavedStateRegistryOwner(owner) + } + + val composeView = ComposeView(context).apply { + setContent { + CompositionLocalProvider( + LocalDensity provides density, + LocalPdfPageConfig provides config, + ) { + content() + } + } + } + + wrapper.addView( + composeView, + FrameLayout.LayoutParams(widthPx, FrameLayout.LayoutParams.WRAP_CONTENT), + ) + pres.setContentView(wrapper) + + // Listen for first layout (composition complete) + composeView.viewTreeObserver.addOnGlobalLayoutListener( + object : ViewTreeObserver.OnGlobalLayoutListener { + override fun onGlobalLayout() { + composeView.viewTreeObserver.removeOnGlobalLayoutListener(this) + // Re-measure with unconstrained height + composeView.measure( + View.MeasureSpec.makeMeasureSpec(widthPx, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), + ) + composeView.layout( + 0, 0, + composeView.measuredWidth, composeView.measuredHeight, + ) + continuation.resume(composeView) + } + }, + ) + + // show() triggers window attachment → composition → layout + pres.show() + } catch (e: Exception) { + continuation.resumeWithException(e) + } + } + + continuation.invokeOnCancellation { + mainHandler.post { close() } + } + } + + override fun close() { + // Lifecycle/Presentation operations must happen on the main thread. + if (Looper.myLooper() == Looper.getMainLooper()) { + closeOnMainThread() + } else { + val latch = CountDownLatch(1) + Handler(Looper.getMainLooper()).post { + closeOnMainThread() + latch.countDown() + } + latch.await() + } + } + + private fun closeOnMainThread() { + presentation?.dismiss() + virtualDisplay?.release() + lifecycleOwner?.destroy() + presentation = null + virtualDisplay = null + lifecycleOwner = null + } +} + +/** + * Minimal lifecycle + saved-state owner for off-screen Compose rendering. + * [initialize] must be called on the main thread before use. + */ +private class OffScreenLifecycleOwner : LifecycleOwner, SavedStateRegistryOwner { + + private val lifecycleRegistry = LifecycleRegistry(this) + override val lifecycle: Lifecycle get() = lifecycleRegistry + + private val savedStateRegistryController = SavedStateRegistryController.create(this) + override val savedStateRegistry: SavedStateRegistry + get() = savedStateRegistryController.savedStateRegistry + + /** Must be called on the main thread. */ + fun initialize() { + savedStateRegistryController.performRestore(null) + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START) + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) + } + + fun destroy() { + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) + } +} diff --git a/compose2pdf/src/commonMain/kotlin/com/chrisjenx/compose2pdf/Compose2PdfException.kt b/compose2pdf/src/commonMain/kotlin/com/chrisjenx/compose2pdf/Compose2PdfException.kt new file mode 100644 index 0000000..33c592c --- /dev/null +++ b/compose2pdf/src/commonMain/kotlin/com/chrisjenx/compose2pdf/Compose2PdfException.kt @@ -0,0 +1,6 @@ +package com.chrisjenx.compose2pdf + +/** + * Exception thrown when PDF rendering fails. + */ +class Compose2PdfException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) diff --git a/compose2pdf/src/main/kotlin/com/chrisjenx/compose2pdf/LocalPdfPageConfig.kt b/compose2pdf/src/commonMain/kotlin/com/chrisjenx/compose2pdf/LocalPdfPageConfig.kt similarity index 100% rename from compose2pdf/src/main/kotlin/com/chrisjenx/compose2pdf/LocalPdfPageConfig.kt rename to compose2pdf/src/commonMain/kotlin/com/chrisjenx/compose2pdf/LocalPdfPageConfig.kt diff --git a/compose2pdf/src/main/kotlin/com/chrisjenx/compose2pdf/PdfLink.kt b/compose2pdf/src/commonMain/kotlin/com/chrisjenx/compose2pdf/PdfLink.kt similarity index 100% rename from compose2pdf/src/main/kotlin/com/chrisjenx/compose2pdf/PdfLink.kt rename to compose2pdf/src/commonMain/kotlin/com/chrisjenx/compose2pdf/PdfLink.kt diff --git a/compose2pdf/src/main/kotlin/com/chrisjenx/compose2pdf/PdfMargins.kt b/compose2pdf/src/commonMain/kotlin/com/chrisjenx/compose2pdf/PdfMargins.kt similarity index 100% rename from compose2pdf/src/main/kotlin/com/chrisjenx/compose2pdf/PdfMargins.kt rename to compose2pdf/src/commonMain/kotlin/com/chrisjenx/compose2pdf/PdfMargins.kt diff --git a/compose2pdf/src/main/kotlin/com/chrisjenx/compose2pdf/PdfPageConfig.kt b/compose2pdf/src/commonMain/kotlin/com/chrisjenx/compose2pdf/PdfPageConfig.kt similarity index 100% rename from compose2pdf/src/main/kotlin/com/chrisjenx/compose2pdf/PdfPageConfig.kt rename to compose2pdf/src/commonMain/kotlin/com/chrisjenx/compose2pdf/PdfPageConfig.kt diff --git a/compose2pdf/src/main/kotlin/com/chrisjenx/compose2pdf/PdfPagination.kt b/compose2pdf/src/commonMain/kotlin/com/chrisjenx/compose2pdf/PdfPagination.kt similarity index 100% rename from compose2pdf/src/main/kotlin/com/chrisjenx/compose2pdf/PdfPagination.kt rename to compose2pdf/src/commonMain/kotlin/com/chrisjenx/compose2pdf/PdfPagination.kt diff --git a/compose2pdf/src/main/kotlin/com/chrisjenx/compose2pdf/PdfRoundedCornerShape.kt b/compose2pdf/src/commonMain/kotlin/com/chrisjenx/compose2pdf/PdfRoundedCornerShape.kt similarity index 100% rename from compose2pdf/src/main/kotlin/com/chrisjenx/compose2pdf/PdfRoundedCornerShape.kt rename to compose2pdf/src/commonMain/kotlin/com/chrisjenx/compose2pdf/PdfRoundedCornerShape.kt diff --git a/compose2pdf/src/main/kotlin/com/chrisjenx/compose2pdf/RenderMode.kt b/compose2pdf/src/commonMain/kotlin/com/chrisjenx/compose2pdf/RenderMode.kt similarity index 100% rename from compose2pdf/src/main/kotlin/com/chrisjenx/compose2pdf/RenderMode.kt rename to compose2pdf/src/commonMain/kotlin/com/chrisjenx/compose2pdf/RenderMode.kt diff --git a/compose2pdf/src/main/kotlin/com/chrisjenx/compose2pdf/internal/PageLayout.kt b/compose2pdf/src/commonMain/kotlin/com/chrisjenx/compose2pdf/internal/PageLayout.kt similarity index 100% rename from compose2pdf/src/main/kotlin/com/chrisjenx/compose2pdf/internal/PageLayout.kt rename to compose2pdf/src/commonMain/kotlin/com/chrisjenx/compose2pdf/internal/PageLayout.kt diff --git a/compose2pdf/src/main/kotlin/com/chrisjenx/compose2pdf/internal/PaginatedColumn.kt b/compose2pdf/src/commonMain/kotlin/com/chrisjenx/compose2pdf/internal/PaginatedColumn.kt similarity index 100% rename from compose2pdf/src/main/kotlin/com/chrisjenx/compose2pdf/internal/PaginatedColumn.kt rename to compose2pdf/src/commonMain/kotlin/com/chrisjenx/compose2pdf/internal/PaginatedColumn.kt diff --git a/compose2pdf/src/main/kotlin/com/chrisjenx/compose2pdf/internal/SvgColor.kt b/compose2pdf/src/commonMain/kotlin/com/chrisjenx/compose2pdf/internal/SvgColor.kt similarity index 100% rename from compose2pdf/src/main/kotlin/com/chrisjenx/compose2pdf/internal/SvgColor.kt rename to compose2pdf/src/commonMain/kotlin/com/chrisjenx/compose2pdf/internal/SvgColor.kt diff --git a/compose2pdf/src/iosMain/kotlin/com/chrisjenx/compose2pdf/Compose2Pdf.ios.kt b/compose2pdf/src/iosMain/kotlin/com/chrisjenx/compose2pdf/Compose2Pdf.ios.kt new file mode 100644 index 0000000..74bd67e --- /dev/null +++ b/compose2pdf/src/iosMain/kotlin/com/chrisjenx/compose2pdf/Compose2Pdf.ios.kt @@ -0,0 +1,38 @@ +package com.chrisjenx.compose2pdf + +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.Density +import com.chrisjenx.compose2pdf.internal.IosPdfRenderer + +/** + * Renders Compose content to a PDF as a ByteArray. + * + * On iOS, this uses Skia SVGCanvas (via Skiko) to convert Compose content to SVG, + * then renders the SVG to PDF using Core Graphics (CGPDFContext). + * + * @param config Page size and margins. Defaults to A4. + * @param density Controls the pixel resolution used during Compose layout. 2f is a good default. + * @param defaultFontFamily The default text font family. Pass null to use system fonts. + * @param pagination Controls page splitting. Defaults to [PdfPagination.AUTO]. + * @param content The composable content to render. + * @return A valid PDF as a ByteArray. + * @throws Compose2PdfException if rendering fails. + */ +fun renderToPdf( + config: PdfPageConfig = PdfPageConfig.A4, + density: Density = Density(2f), + defaultFontFamily: FontFamily? = null, + pagination: PdfPagination = PdfPagination.AUTO, + content: @Composable () -> Unit, +): ByteArray { + try { + return IosPdfRenderer.render(config, density, defaultFontFamily, pagination, content) + } catch (e: Compose2PdfException) { + throw e + } catch (e: IllegalArgumentException) { + throw e + } catch (e: Exception) { + throw Compose2PdfException("Failed to render PDF: ${e.message}", e) + } +} diff --git a/compose2pdf/src/iosMain/kotlin/com/chrisjenx/compose2pdf/internal/ComposeToSvg.kt b/compose2pdf/src/iosMain/kotlin/com/chrisjenx/compose2pdf/internal/ComposeToSvg.kt new file mode 100644 index 0000000..f55e9ea --- /dev/null +++ b/compose2pdf/src/iosMain/kotlin/com/chrisjenx/compose2pdf/internal/ComposeToSvg.kt @@ -0,0 +1,119 @@ +@file:OptIn(InternalComposeUiApi::class) + +package com.chrisjenx.compose2pdf.internal + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.ui.InternalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asComposeCanvas +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.scene.CanvasLayersComposeScene +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntSize +import kotlinx.coroutines.Dispatchers +import org.jetbrains.skia.DynamicMemoryWStream +import org.jetbrains.skia.PictureRecorder +import org.jetbrains.skia.Rect +import org.jetbrains.skia.svg.SVGCanvas + +/** + * Renders Compose content to SVG via Skia's SVGCanvas on iOS. + * Same logic as the JVM version but uses DynamicMemoryWStream instead of ByteArrayOutputStream. + */ +internal object ComposeToSvg { + + fun render( + widthPx: Int, + heightPx: Int, + density: Density, + content: @Composable () -> Unit, + ): String { + val recorder = PictureRecorder() + val recordCanvas = recorder.beginRecording( + Rect.makeWH(widthPx.toFloat(), heightPx.toFloat()) + ) + + val scene = CanvasLayersComposeScene( + density = density, + size = IntSize(widthPx, heightPx), + coroutineContext = Dispatchers.Unconfined, + invalidate = {}, + ) + scene.setContent(content) + scene.render(recordCanvas.asComposeCanvas(), nanoTime = 0) + scene.close() + + val picture = recorder.finishRecordingAsPicture() + + val wstream = DynamicMemoryWStream() + val svgCanvas = SVGCanvas.make( + Rect.makeWH(widthPx.toFloat(), heightPx.toFloat()), + wstream, + convertTextToPaths = false, + prettyXML = false, + ) + + picture.playback(svgCanvas) + svgCanvas.close() + val size = wstream.bytesWritten() + val buffer = ByteArray(size) + wstream.read(buffer, 0, size) + wstream.close() + picture.close() + + return buffer.decodeToString() + } + + data class RenderResult( + val svg: String, + val measuredHeightPx: Int, + ) + + fun renderWithMeasurement( + widthPx: Int, + maxHeightPx: Int, + density: Density, + content: @Composable () -> Unit, + ): RenderResult { + var measuredHeight = 0 + val svg = render(widthPx, maxHeightPx, density) { + Box(Modifier.onGloballyPositioned { coords -> + measuredHeight = coords.size.height + }) { + content() + } + } + return RenderResult(svg, measuredHeight) + } + + fun measureContentHeight( + widthPx: Int, + maxHeightPx: Int, + density: Density, + content: @Composable () -> Unit, + ): Int { + var measuredHeight = 0 + val recorder = PictureRecorder() + val recordCanvas = recorder.beginRecording( + Rect.makeWH(widthPx.toFloat(), maxHeightPx.toFloat()) + ) + val scene = CanvasLayersComposeScene( + density = density, + size = IntSize(widthPx, maxHeightPx), + coroutineContext = Dispatchers.Unconfined, + invalidate = {}, + ) + scene.setContent { + Box(Modifier.onGloballyPositioned { coords -> + measuredHeight = coords.size.height + }) { + content() + } + } + scene.render(recordCanvas.asComposeCanvas(), nanoTime = 0) + scene.close() + recorder.finishRecordingAsPicture().close() + return measuredHeight + } +} diff --git a/compose2pdf/src/iosMain/kotlin/com/chrisjenx/compose2pdf/internal/CoreGraphicsPathParser.kt b/compose2pdf/src/iosMain/kotlin/com/chrisjenx/compose2pdf/internal/CoreGraphicsPathParser.kt new file mode 100644 index 0000000..6969430 --- /dev/null +++ b/compose2pdf/src/iosMain/kotlin/com/chrisjenx/compose2pdf/internal/CoreGraphicsPathParser.kt @@ -0,0 +1,271 @@ +package com.chrisjenx.compose2pdf.internal + +import kotlinx.cinterop.ExperimentalForeignApi +import platform.CoreGraphics.CGContextAddCurveToPoint +import platform.CoreGraphics.CGContextAddLineToPoint +import platform.CoreGraphics.CGContextClosePath +import platform.CoreGraphics.CGContextMoveToPoint +import platform.CoreGraphics.CGContextRef +import platform.CoreGraphics.CGFloat +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.acos +import kotlin.math.ceil +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt +import kotlin.math.tan + +/** + * Parses SVG path data strings and emits corresponding Core Graphics path operations. + * + * Supports all SVG path commands: M/m, L/l, H/h, V/v, C/c, S/s, Q/q, T/t, A/a, Z/z. + * Port of [SvgPathParser] from JVM (PDFBox) to iOS (Core Graphics). + */ +@OptIn(ExperimentalForeignApi::class) +internal object CoreGraphicsPathParser { + + private val PATH_TOKEN_RE = + Regex("""[MmLlHhVvCcSsQqTtAaZz]|[+-]?(?:\d+\.?\d*|\.\d+)""") + private const val PATH_COMMANDS = "MmLlHhVvCcSsQqTtAaZz" + + /** + * Parses an SVG path data string and emits corresponding Core Graphics path operations. + */ + fun parse(d: String, ctx: CGContextRef) { + val tokens = PATH_TOKEN_RE.findAll(d).map { it.value }.toList() + var i = 0 + var cx: CGFloat = 0.0; var cy: CGFloat = 0.0 // current point + var sx: CGFloat = 0.0; var sy: CGFloat = 0.0 // subpath start (for Z) + var lastCmd = ' ' + // Tracking for smooth curve reflection + var lcp2x: CGFloat = 0.0; var lcp2y: CGFloat = 0.0 // last cubic control point 2 (for S/s) + var lqpx: CGFloat = 0.0; var lqpy: CGFloat = 0.0 // last quadratic control point (for T/t) + + fun nf(): CGFloat = tokens[i++].toDouble() + + while (i < tokens.size) { + val tok = tokens[i] + val cmd: Char + if (tok.length == 1 && tok[0] in PATH_COMMANDS) { + cmd = tok[0] + i++ + } else { + // Implicit repeat: M->L, m->l, otherwise same command + cmd = when (lastCmd) { + 'M' -> 'L' + 'm' -> 'l' + 'Z', 'z', ' ' -> break + else -> lastCmd + } + } + + when (cmd) { + 'M' -> { + cx = nf(); cy = nf(); sx = cx; sy = cy + CGContextMoveToPoint(ctx, cx, cy) + } + 'm' -> { + cx += nf(); cy += nf(); sx = cx; sy = cy + CGContextMoveToPoint(ctx, cx, cy) + } + 'L' -> { cx = nf(); cy = nf(); CGContextAddLineToPoint(ctx, cx, cy) } + 'l' -> { cx += nf(); cy += nf(); CGContextAddLineToPoint(ctx, cx, cy) } + 'H' -> { cx = nf(); CGContextAddLineToPoint(ctx, cx, cy) } + 'h' -> { cx += nf(); CGContextAddLineToPoint(ctx, cx, cy) } + 'V' -> { cy = nf(); CGContextAddLineToPoint(ctx, cx, cy) } + 'v' -> { cy += nf(); CGContextAddLineToPoint(ctx, cx, cy) } + 'C' -> { + val x1 = nf(); val y1 = nf() + val x2 = nf(); val y2 = nf() + cx = nf(); cy = nf() + CGContextAddCurveToPoint(ctx, x1, y1, x2, y2, cx, cy) + lcp2x = x2; lcp2y = y2 + } + 'c' -> { + val x1 = cx + nf(); val y1 = cy + nf() + val x2 = cx + nf(); val y2 = cy + nf() + cx += nf(); cy += nf() + CGContextAddCurveToPoint(ctx, x1, y1, x2, y2, cx, cy) + lcp2x = x2; lcp2y = y2 + } + 'S' -> { + val x1 = 2 * cx - lcp2x; val y1 = 2 * cy - lcp2y + val x2 = nf(); val y2 = nf() + cx = nf(); cy = nf() + CGContextAddCurveToPoint(ctx, x1, y1, x2, y2, cx, cy) + lcp2x = x2; lcp2y = y2 + } + 's' -> { + val x1 = 2 * cx - lcp2x; val y1 = 2 * cy - lcp2y + val x2 = cx + nf(); val y2 = cy + nf() + cx += nf(); cy += nf() + CGContextAddCurveToPoint(ctx, x1, y1, x2, y2, cx, cy) + lcp2x = x2; lcp2y = y2 + } + 'Q' -> { + val qx = nf(); val qy = nf() + val x = nf(); val y = nf() + quadToCubic(ctx, cx, cy, qx, qy, x, y) + lqpx = qx; lqpy = qy; cx = x; cy = y + } + 'q' -> { + val qx = cx + nf(); val qy = cy + nf() + val x = cx + nf(); val y = cy + nf() + quadToCubic(ctx, cx, cy, qx, qy, x, y) + lqpx = qx; lqpy = qy; cx = x; cy = y + } + 'T' -> { + val qx = 2 * cx - lqpx; val qy = 2 * cy - lqpy + val x = nf(); val y = nf() + quadToCubic(ctx, cx, cy, qx, qy, x, y) + lqpx = qx; lqpy = qy; cx = x; cy = y + } + 't' -> { + val qx = 2 * cx - lqpx; val qy = 2 * cy - lqpy + val x = cx + nf(); val y = cy + nf() + quadToCubic(ctx, cx, cy, qx, qy, x, y) + lqpx = qx; lqpy = qy; cx = x; cy = y + } + 'A' -> { + val rx = nf(); val ry = nf(); val rot = nf() + val la = nf().toInt() != 0; val sw = nf().toInt() != 0 + val x = nf(); val y = nf() + arcToCubic(ctx, cx, cy, rx, ry, rot, la, sw, x, y) + cx = x; cy = y + } + 'a' -> { + val rx = nf(); val ry = nf(); val rot = nf() + val la = nf().toInt() != 0; val sw = nf().toInt() != 0 + val x = cx + nf(); val y = cy + nf() + arcToCubic(ctx, cx, cy, rx, ry, rot, la, sw, x, y) + cx = x; cy = y + } + 'Z', 'z' -> { + CGContextClosePath(ctx); cx = sx; cy = sy + } + } + + // Reset smooth curve control points when previous wasn't a matching type + if (cmd !in "CcSs") { lcp2x = cx; lcp2y = cy } + if (cmd !in "QqTt") { lqpx = cx; lqpy = cy } + lastCmd = cmd + } + } + + /** Converts a quadratic Bezier to cubic Bezier (Core Graphics only supports cubic). */ + private fun quadToCubic( + ctx: CGContextRef, + x0: CGFloat, y0: CGFloat, qx: CGFloat, qy: CGFloat, x: CGFloat, y: CGFloat, + ) { + val c1x = x0 + 2.0 / 3.0 * (qx - x0) + val c1y = y0 + 2.0 / 3.0 * (qy - y0) + val c2x = x + 2.0 / 3.0 * (qx - x) + val c2y = y + 2.0 / 3.0 * (qy - y) + CGContextAddCurveToPoint(ctx, c1x, c1y, c2x, c2y, x, y) + } + + /** + * Converts an SVG arc to one or more cubic Bezier curves. + * Implements the SVG spec endpoint-to-center arc parameterization (F.6). + */ + private fun arcToCubic( + ctx: CGContextRef, + x1: CGFloat, y1: CGFloat, + rxIn: CGFloat, ryIn: CGFloat, + xRotDeg: CGFloat, + largeArc: Boolean, + sweep: Boolean, + x2: CGFloat, y2: CGFloat, + ) { + // Degenerate: same point -> no-op + if (x1 == x2 && y1 == y2) return + // Degenerate: zero radius -> straight line + var rx = abs(rxIn) + var ry = abs(ryIn) + if (rx == 0.0 || ry == 0.0) { CGContextAddLineToPoint(ctx, x2, y2); return } + + val phi = xRotDeg * PI / 180.0 + val cp = cos(phi); val sp = sin(phi) + + // Step 1: Compute (x1', y1') in rotated frame + val dx = (x1 - x2) / 2.0 + val dy = (y1 - y2) / 2.0 + val x1p = cp * dx + sp * dy + val y1p = -sp * dx + cp * dy + + // Step 2: Ensure radii are large enough + val lambda = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry) + if (lambda > 1.0) { + val s = sqrt(lambda); rx *= s; ry *= s + } + val rxSq = rx * rx; val rySq = ry * ry + val x1pSq = x1p * x1p; val y1pSq = y1p * y1p + + // Step 3: Compute center point in rotated frame + var sq = ((rxSq * rySq - rxSq * y1pSq - rySq * x1pSq) / + (rxSq * y1pSq + rySq * x1pSq)).coerceAtLeast(0.0) + sq = sqrt(sq) + if (largeArc == sweep) sq = -sq + val cxp = sq * rx * y1p / ry + val cyp = -sq * ry * x1p / rx + + // Transform center to world coordinates + val mx = (x1 + x2) / 2.0 + val my = (y1 + y2) / 2.0 + val ccx = cp * cxp - sp * cyp + mx + val ccy = sp * cxp + cp * cyp + my + + // Step 4: Compute start angle and sweep + val theta1 = vecAngle(1.0, 0.0, (x1p - cxp) / rx, (y1p - cyp) / ry) + var dTheta = vecAngle( + (x1p - cxp) / rx, (y1p - cyp) / ry, + (-x1p - cxp) / rx, (-y1p - cyp) / ry, + ) + if (!sweep && dTheta > 0) dTheta -= 2 * PI + if (sweep && dTheta < 0) dTheta += 2 * PI + + // Step 5: Split into <=90deg segments, approximate each as cubic Bezier + val numSegs = ceil(abs(dTheta) / (PI / 2)).toInt().coerceAtLeast(1) + val segAngle = dTheta / numSegs + val alpha = 4.0 / 3.0 * tan(segAngle / 4.0) + + var t = theta1 + for (s in 0 until numSegs) { + val t2 = t + segAngle + val cosT1 = cos(t); val sinT1 = sin(t) + val cosT2 = cos(t2); val sinT2 = sin(t2) + + // Points on the ellipse in local frame + val ep1x = rx * cosT1; val ep1y = ry * sinT1 + val ep2x = rx * cosT2; val ep2y = ry * sinT2 + + // Control points via tangent vectors + val c1x = ep1x - alpha * rx * sinT1 + val c1y = ep1y + alpha * ry * cosT1 + val c2x = ep2x + alpha * rx * sinT2 + val c2y = ep2y - alpha * ry * cosT2 + + // Transform to world coordinates (rotate by phi, translate by center) + fun wx(ex: Double, ey: Double): CGFloat = cp * ex - sp * ey + ccx + fun wy(ex: Double, ey: Double): CGFloat = sp * ex + cp * ey + ccy + + CGContextAddCurveToPoint( + ctx, + wx(c1x, c1y), wy(c1x, c1y), + wx(c2x, c2y), wy(c2x, c2y), + wx(ep2x, ep2y), wy(ep2x, ep2y), + ) + t = t2 + } + } + + /** Computes the angle between two 2D vectors per SVG spec F.6.5. */ + private fun vecAngle(ux: Double, uy: Double, vx: Double, vy: Double): Double { + val dot = ux * vx + uy * vy + val len = sqrt(ux * ux + uy * uy) * sqrt(vx * vx + vy * vy) + var a = acos((dot / len).coerceIn(-1.0, 1.0)) + if (ux * vy - uy * vx < 0) a = -a + return a + } +} diff --git a/compose2pdf/src/iosMain/kotlin/com/chrisjenx/compose2pdf/internal/CoreGraphicsPdfConverter.kt b/compose2pdf/src/iosMain/kotlin/com/chrisjenx/compose2pdf/internal/CoreGraphicsPdfConverter.kt new file mode 100644 index 0000000..e32d998 --- /dev/null +++ b/compose2pdf/src/iosMain/kotlin/com/chrisjenx/compose2pdf/internal/CoreGraphicsPdfConverter.kt @@ -0,0 +1,933 @@ +@file:OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) + +package com.chrisjenx.compose2pdf.internal + +import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.DoubleVar +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.allocArray +import kotlinx.cinterop.get +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.UShortVar +import kotlinx.cinterop.set +import platform.CoreFoundation.CFAttributedStringCreate +import platform.CoreFoundation.CFDataGetBytePtr +import platform.CoreFoundation.CFDictionaryCreateMutable +import platform.CoreFoundation.CFDictionarySetValue +import platform.CoreFoundation.CFMutableDataRef +import platform.CoreFoundation.CFRelease +import platform.CoreFoundation.CFStringRef +import platform.CoreFoundation.CFTypeRef +import platform.CoreFoundation.kCFAllocatorDefault +import platform.CoreFoundation.kCFTypeDictionaryKeyCallBacks +import platform.CoreFoundation.kCFTypeDictionaryValueCallBacks +import platform.CoreGraphics.CGAffineTransformMake +import platform.CoreGraphics.CGColorCreate +import platform.CoreGraphics.CGColorRelease +import platform.CoreGraphics.CGColorSpaceCreateDeviceRGB +import platform.CoreGraphics.CGColorSpaceRelease +import platform.CoreGraphics.CGContextAddCurveToPoint +import platform.CoreGraphics.CGContextAddLineToPoint +import platform.CoreGraphics.CGContextAddRect +import platform.CoreGraphics.CGContextBeginPath +import platform.CoreGraphics.CGContextClip +import platform.CoreGraphics.CGContextClosePath +import platform.CoreGraphics.CGContextConcatCTM +import platform.CoreGraphics.CGContextDrawImage +import platform.CoreGraphics.CGContextDrawPath +import platform.CoreGraphics.CGContextMoveToPoint +import platform.CoreGraphics.CGContextRef +import platform.CoreGraphics.CGContextRestoreGState +import platform.CoreGraphics.CGContextSaveGState +import platform.CoreGraphics.CGContextScaleCTM +import platform.CoreGraphics.CGContextSetAlpha +import platform.CoreGraphics.CGContextSetLineCap +import platform.CoreGraphics.CGContextSetLineDash +import platform.CoreGraphics.CGContextSetLineJoin +import platform.CoreGraphics.CGContextSetLineWidth +import platform.CoreGraphics.CGContextSetRGBFillColor +import platform.CoreGraphics.CGContextSetRGBStrokeColor +import platform.CoreGraphics.CGContextTranslateCTM +import platform.CoreGraphics.CGDataConsumerCreateWithCFData +import platform.CoreGraphics.CGDataConsumerRelease +import platform.CoreGraphics.CGFloat +import platform.CoreGraphics.CGImageRelease +import platform.CoreGraphics.CGPDFContextBeginPage +import platform.CoreGraphics.CGPDFContextClose +import platform.CoreGraphics.CGPDFContextCreate +import platform.CoreGraphics.CGPDFContextEndPage +import platform.CoreGraphics.CGRect +import platform.CoreGraphics.CGRectMake +import platform.CoreGraphics.CGLineCap +import platform.CoreGraphics.CGLineJoin +import platform.CoreGraphics.CGPathDrawingMode +import platform.CoreGraphics.CGPoint +import platform.CoreText.CTFontCreateWithName +import platform.CoreText.CTFontDrawGlyphs +import platform.CoreText.CTFontGetGlyphsForCharacters +import platform.CoreText.CTLineCreateWithAttributedString +import platform.CoreText.CTLineDraw +import platform.CoreText.kCTFontAttributeName +import platform.CoreText.kCTForegroundColorAttributeName +import platform.Foundation.CFBridgingRetain +import platform.Foundation.NSData +import platform.Foundation.NSMutableData +import platform.Foundation.create +import platform.ImageIO.CGImageSourceCreateImageAtIndex +import platform.ImageIO.CGImageSourceCreateWithData +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.tan + +/** + * Converts Skia-generated SVG to vector PDF using Core Graphics on iOS. + * + * Equivalent to [SvgToPdfConverter] on JVM (which uses PDFBox), this converter + * handles SVG elements produced by Skia's SVGCanvas: shapes (rect, circle, ellipse, + * line, polyline, polygon, path), text, groups, definitions (defs/use), clipping, + * transforms, fills, strokes, and opacity. + * + * Uses [SvgParser] to parse SVG XML into [SvgElement] trees, then renders each + * element to a Core Graphics PDF context. + */ +internal object CoreGraphicsPdfConverter { + + /** + * Renders a single SVG string as a single PDF page. + * + * @param svg The SVG string to render. + * @param pageWidthPt Page width in PDF points. + * @param pageHeightPt Page height in PDF points. + * @return The rendered PDF as a ByteArray. + */ + fun renderSinglePage( + svg: String, + pageWidthPt: Float, + pageHeightPt: Float, + ): ByteArray { + val (svgRoot, defs) = SvgParser.parse(svg) + return createPdf(pageWidthPt, pageHeightPt) { ctx -> + val svgWidth = svgRoot.attributes["width"]?.toDoubleOrNull() ?: pageWidthPt.toDouble() + val svgHeight = svgRoot.attributes["height"]?.toDoubleOrNull() ?: pageHeightPt.toDouble() + val scaleX = pageWidthPt / svgWidth + val scaleY = pageHeightPt / svgHeight + + beginPage(ctx, pageWidthPt, pageHeightPt) + // PDF: bottom-left origin (Y-up). SVG: top-left origin (Y-down). Flip Y and scale. + applySvgToPageTransform(ctx, scaleX, scaleY, pageHeightPt.toDouble()) + PageRenderer(ctx, defs).renderChildren(svgRoot) + endPage(ctx) + } + } + + /** + * Renders a tall SVG as multiple auto-paginated PDF pages, slicing vertically + * with proper margins, clipping, and page offsets. + * + * @param svg The tall SVG string to render. + * @param layout Page dimensions and margin layout. + * @param totalContentHeightPt Total content height in PDF points. + * @param density The density used during Compose rendering. + * @param maxPages Maximum number of pages to generate. + * @return The rendered PDF as a ByteArray. + */ + fun renderAutoPages( + svg: String, + layout: PageLayout, + totalContentHeightPt: Float, + density: Float, + maxPages: Int, + ): ByteArray { + val (svgRoot, defs) = SvgParser.parse(svg) + val pageCount = kotlin.math.ceil(totalContentHeightPt.toDouble() / layout.contentHeightPt) + .toInt().coerceIn(1, maxPages) + + return createPdf(layout.pageWidthPt, layout.pageHeightPt) { ctx -> + for (pageIndex in 0 until pageCount) { + addPageSlice(ctx, svgRoot, defs, layout, pageIndex, density) + } + } + } + + // -- PDF document lifecycle -- + + /** + * Creates a PDF document, executes the rendering block, and returns the result as ByteArray. + */ + private inline fun createPdf( + pageWidthPt: Float, + pageHeightPt: Float, + block: (CGContextRef) -> Unit, + ): ByteArray = memScoped { + val mutableData = NSMutableData() + @Suppress("UNCHECKED_CAST") + val cfData = CFBridgingRetain(mutableData) as CFMutableDataRef + val consumer = CGDataConsumerCreateWithCFData(cfData) + + val rect = CGRectMake(0.0, 0.0, pageWidthPt.toDouble(), pageHeightPt.toDouble()) + + val pdfContext = CGPDFContextCreate(consumer, rect.ptr, null) + ?: run { + CGDataConsumerRelease(consumer) + CFRelease(cfData) + throw IllegalStateException("Failed to create CGPDFContext") + } + + try { + block(pdfContext) + CGPDFContextClose(pdfContext) + } finally { + CFRelease(pdfContext) + CGDataConsumerRelease(consumer) + } + + // Extract bytes from NSMutableData + val bytes = mutableData.toByteArray() + CFRelease(cfData) + bytes + } + + private fun beginPage(ctx: CGContextRef, widthPt: Float, heightPt: Float) { + // CGPDFContextBeginPage with null page dict uses the context's default media box + CGPDFContextBeginPage(ctx, null) + } + + private fun endPage(ctx: CGContextRef) { + CGPDFContextEndPage(ctx) + } + + /** + * Applies the initial SVG-to-PDF coordinate transform. + * SVG Y-down -> PDF Y-up: scale(sx, -sy) then translate(0, -pageHeight/sy) + * Combined as matrix: (sx, 0, 0, -sy, 0, pageHeight) + */ + private fun applySvgToPageTransform( + ctx: CGContextRef, + scaleX: Double, + scaleY: Double, + pageHeightPt: Double, + ) { + CGContextConcatCTM(ctx, CGAffineTransformMake(scaleX, 0.0, 0.0, -scaleY, 0.0, pageHeightPt)) + } + + private fun addPageSlice( + ctx: CGContextRef, + svgRoot: SvgElement, + defs: Map, + layout: PageLayout, + pageIndex: Int, + density: Float, + ) { + beginPage(ctx, layout.pageWidthPt, layout.pageHeightPt) + + CGContextSaveGState(ctx) + + // Clip to content area (in PDF Y-up coordinates) + val marginBottom = layout.pageHeightPt - layout.marginTopPt - layout.contentHeightPt + CGContextAddRect( + ctx, + CGRectMake( + layout.marginLeftPt.toDouble(), + marginBottom.toDouble(), + layout.contentWidthPt.toDouble(), + layout.contentHeightPt.toDouble(), + ) + ) + CGContextClip(ctx) + + // Apply content area transform: scale + Y-flip + margin offset + vertical pagination + val scale = 1.0 / density + val verticalOffsetPt = pageIndex * layout.contentHeightPt + CGContextConcatCTM( + ctx, + CGAffineTransformMake( + scale.toDouble(), 0.0, + 0.0, -scale.toDouble(), + layout.marginLeftPt.toDouble(), + (layout.pageHeightPt - layout.marginTopPt + verticalOffsetPt).toDouble(), + ) + ) + + PageRenderer(ctx, defs).renderChildren(svgRoot) + CGContextRestoreGState(ctx) + + endPage(ctx) + } + + // -- NSMutableData extension -- + + private fun NSMutableData.toByteArray(): ByteArray { + val length = this.length.toInt() + if (length == 0) return ByteArray(0) + @Suppress("UNCHECKED_CAST") + val cfData = CFBridgingRetain(this) as platform.CoreFoundation.CFDataRef + val ptr = CFDataGetBytePtr(cfData) + val result = ByteArray(length) + if (ptr != null) { + for (i in 0 until length) { + result[i] = ptr[i].toByte() + } + } + CFRelease(cfData) + return result + } + + // -- Shape drawing helpers -- + + /** Bezier approximation constant for circles/ellipses: 4 * (sqrt(2) - 1) / 3 */ + private const val KAPPA: CGFloat = 0.5522847498 + + /** Draws an ellipse using 4 cubic Bezier curves (standard approximation). */ + private fun drawEllipse(ctx: CGContextRef, cx: CGFloat, cy: CGFloat, rx: CGFloat, ry: CGFloat) { + val kx = rx * KAPPA + val ky = ry * KAPPA + CGContextMoveToPoint(ctx, cx + rx, cy) + CGContextAddCurveToPoint(ctx, cx + rx, cy + ky, cx + kx, cy + ry, cx, cy + ry) + CGContextAddCurveToPoint(ctx, cx - kx, cy + ry, cx - rx, cy + ky, cx - rx, cy) + CGContextAddCurveToPoint(ctx, cx - rx, cy - ky, cx - kx, cy - ry, cx, cy - ry) + CGContextAddCurveToPoint(ctx, cx + kx, cy - ry, cx + rx, cy - ky, cx + rx, cy) + CGContextClosePath(ctx) + } + + /** Draws a rounded rectangle using line segments and cubic Bezier corners. */ + private fun drawRoundedRect( + ctx: CGContextRef, + x: CGFloat, y: CGFloat, w: CGFloat, h: CGFloat, rx: CGFloat, ry: CGFloat, + ) { + val kx = rx * KAPPA + val ky = ry * KAPPA + CGContextMoveToPoint(ctx, x + rx, y) + // Top edge -> top-right corner + CGContextAddLineToPoint(ctx, x + w - rx, y) + CGContextAddCurveToPoint(ctx, x + w - rx + kx, y, x + w, y + ry - ky, x + w, y + ry) + // Right edge -> bottom-right corner + CGContextAddLineToPoint(ctx, x + w, y + h - ry) + CGContextAddCurveToPoint(ctx, x + w, y + h - ry + ky, x + w - rx + kx, y + h, x + w - rx, y + h) + // Bottom edge -> bottom-left corner + CGContextAddLineToPoint(ctx, x + rx, y + h) + CGContextAddCurveToPoint(ctx, x + rx - kx, y + h, x, y + h - ry + ky, x, y + h - ry) + // Left edge -> top-left corner + CGContextAddLineToPoint(ctx, x, y + ry) + CGContextAddCurveToPoint(ctx, x, y + ry - ky, x + rx - kx, y, x + rx, y) + CGContextClosePath(ctx) + } + + /** Builds SVG path data for a rounded rectangle (for clipping via CoreGraphicsPathParser). */ + private fun buildRoundedRectPathData( + x: CGFloat, y: CGFloat, w: CGFloat, h: CGFloat, rx: CGFloat, ry: CGFloat, + ): String = buildString { + append("M${x + rx},${y}") + append("L${x + w - rx},${y}") + append("A$rx,$ry,0,0,1,${x + w},${y + ry}") + append("L${x + w},${y + h - ry}") + append("A$rx,$ry,0,0,1,${x + w - rx},${y + h}") + append("L${x + rx},${y + h}") + append("A$rx,$ry,0,0,1,${x},${y + h - ry}") + append("L${x},${y + ry}") + append("A$rx,$ry,0,0,1,${x + rx},${y}") + append("Z") + } + + // ======================================================================== + // PageRenderer — renders SVG elements to a Core Graphics PDF context. + // ======================================================================== + + /** + * Renders SVG elements to a Core Graphics PDF context for a single page. + * Holds per-page rendering state. + */ + private class PageRenderer( + private val ctx: CGContextRef, + private val defs: Map, + ) { + + // -- Element dispatch -- + + fun renderChildren(parent: SvgElement) { + for (child in parent.children) { + renderElement(child) + } + } + + private fun renderElement(elem: SvgElement) { + when (elem.name) { + "defs", "clipPath" -> {} // Skip definition elements + "rect" -> renderRect(elem) + "circle" -> renderCircle(elem) + "ellipse" -> renderEllipse(elem) + "line" -> renderLine(elem) + "polyline" -> renderPolyShape(elem, close = false) + "polygon" -> renderPolyShape(elem, close = true) + "path" -> renderPath(elem) + "text" -> renderText(elem) + "g" -> renderGroup(elem) + "use" -> renderUse(elem) + "image" -> renderImage(elem) + } + } + + // -- Shape elements -- + + private fun renderRect(elem: SvgElement) { + CGContextSaveGState(ctx) + applyTransform(elem); applyOpacity(elem) + + val x = elem.attr("x")?.toDoubleOrNull() ?: 0.0 + val y = elem.attr("y")?.toDoubleOrNull() ?: 0.0 + val w = elem.attr("width")?.toDoubleOrNull() ?: return restore() + val h = elem.attr("height")?.toDoubleOrNull() ?: return restore() + val rx = elem.attr("rx")?.toDoubleOrNull() ?: 0.0 + val ry = elem.attr("ry")?.toDoubleOrNull() ?: rx + + CGContextBeginPath(ctx) + if (rx > 0.0 || ry > 0.0) { + drawRoundedRect( + ctx, x, y, w, h, + rx.coerceAtMost(w / 2), + ry.coerceAtMost(h / 2), + ) + } else { + CGContextAddRect(ctx, CGRectMake(x, y, w, h)) + } + fillAndStroke(elem) + CGContextRestoreGState(ctx) + } + + private fun renderCircle(elem: SvgElement) { + CGContextSaveGState(ctx) + applyTransform(elem); applyOpacity(elem) + + val cx = elem.attr("cx")?.toDoubleOrNull() ?: 0.0 + val cy = elem.attr("cy")?.toDoubleOrNull() ?: 0.0 + val r = elem.attr("r")?.toDoubleOrNull() ?: return restore() + + CGContextBeginPath(ctx) + drawEllipse(ctx, cx, cy, r, r) + fillAndStroke(elem) + CGContextRestoreGState(ctx) + } + + private fun renderEllipse(elem: SvgElement) { + CGContextSaveGState(ctx) + applyTransform(elem); applyOpacity(elem) + + val cx = elem.attr("cx")?.toDoubleOrNull() ?: 0.0 + val cy = elem.attr("cy")?.toDoubleOrNull() ?: 0.0 + val rx = elem.attr("rx")?.toDoubleOrNull() ?: return restore() + val ry = elem.attr("ry")?.toDoubleOrNull() ?: return restore() + + CGContextBeginPath(ctx) + drawEllipse(ctx, cx, cy, rx, ry) + fillAndStroke(elem) + CGContextRestoreGState(ctx) + } + + private fun renderLine(elem: SvgElement) { + CGContextSaveGState(ctx) + applyTransform(elem); applyOpacity(elem) + + val x1 = elem.attr("x1")?.toDoubleOrNull() ?: 0.0 + val y1 = elem.attr("y1")?.toDoubleOrNull() ?: 0.0 + val x2 = elem.attr("x2")?.toDoubleOrNull() ?: 0.0 + val y2 = elem.attr("y2")?.toDoubleOrNull() ?: 0.0 + + CGContextBeginPath(ctx) + CGContextMoveToPoint(ctx, x1, y1) + CGContextAddLineToPoint(ctx, x2, y2) + // Lines only stroke, never fill + applyStrokeState(elem) + elem.attr("stroke")?.takeIf { it != "none" }?.let { strokeVal -> + SvgColorParser.parse(strokeVal)?.let { c -> + CGContextSetRGBStrokeColor(ctx, c.r.toDouble(), c.g.toDouble(), c.b.toDouble(), 1.0) + } + } + CGContextDrawPath(ctx, CGPathDrawingMode.kCGPathStroke) + CGContextRestoreGState(ctx) + } + + private fun renderPolyShape(elem: SvgElement, close: Boolean) { + CGContextSaveGState(ctx) + applyTransform(elem); applyOpacity(elem) + + val points = elem.attr("points") ?: return restore() + val coords = points.trim().split(Regex("[,\\s]+")).mapNotNull { it.toDoubleOrNull() } + if (coords.size < 4) return restore() + + CGContextBeginPath(ctx) + CGContextMoveToPoint(ctx, coords[0], coords[1]) + for (i in 2 until coords.size - 1 step 2) { + CGContextAddLineToPoint(ctx, coords[i], coords[i + 1]) + } + if (close) CGContextClosePath(ctx) + fillAndStroke(elem) + CGContextRestoreGState(ctx) + } + + private fun renderPath(elem: SvgElement) { + CGContextSaveGState(ctx) + applyTransform(elem); applyOpacity(elem) + + val d = elem.attr("d") + if (!d.isNullOrEmpty()) { + CGContextBeginPath(ctx) + CoreGraphicsPathParser.parse(d, ctx) + fillAndStroke(elem) + } + CGContextRestoreGState(ctx) + } + + private fun renderText(elem: SvgElement) { + CGContextSaveGState(ctx) + applyTransform(elem); applyOpacity(elem) + + val fontSize = elem.attr("font-size")?.toDoubleOrNull() ?: 12.0 + val text = elem.textContent.trim() + if (text.isEmpty()) return restore() + + val fillColor = elem.attr("fill") + ?.takeIf { it != "none" } + ?.let { SvgColorParser.parse(it) } + ?: SvgColor(0f, 0f, 0f) // SVG default: black + + // Resolve font + val fontFamily = elem.attr("font-family")?.removeSurrounding("'")?.removeSurrounding("\"") ?: "Helvetica" + val fontWeight = elem.attr("font-weight") + val fontStyle = elem.attr("font-style") + val ctFontName = resolveFontName(fontFamily, fontWeight, fontStyle) + + @Suppress("UNCHECKED_CAST") + val ctFontNameRef = platform.Foundation.CFBridgingRetain(ctFontName as platform.Foundation.NSString) as CFStringRef + val ctFont = CTFontCreateWithName(ctFontNameRef, fontSize, null) + CFRelease(ctFontNameRef) + if (ctFont == null) return restore() + + val xAttr = elem.attr("x") ?: "" + val xPositions = xAttr.split(",").mapNotNull { it.trim().toDoubleOrNull() } + val yOffset = (elem.attr("y") ?: "").split(",") + .firstOrNull()?.trim()?.toDoubleOrNull() ?: fontSize + + // Counter-flip Y for text: undo global Y-flip so glyphs render right-side up + // Matrix: (1, 0, 0, -1, 0, 2*yOffset) + CGContextConcatCTM(ctx, CGAffineTransformMake(1.0, 0.0, 0.0, -1.0, 0.0, 2.0 * yOffset)) + + if (xPositions.size > 1 && xPositions.size >= text.length) { + // CTFontDrawGlyphs bypasses CTLine's text layout engine, placing each + // glyph exactly at the specified coordinate without bearing adjustments. + memScoped { + val count = text.length + val characters = allocArray(count) + val positions = allocArray(count) + for (i in 0 until count) { + characters[i] = text[i].code.toUShort() + positions[i].x = xPositions[i] + positions[i].y = yOffset + } + val glyphs = allocArray(count) + CTFontGetGlyphsForCharacters(ctFont, characters, glyphs, count.toLong()) + CGContextSetRGBFillColor(ctx, fillColor.r.toDouble(), fillColor.g.toDouble(), fillColor.b.toDouble(), 1.0) + CTFontDrawGlyphs(ctFont, glyphs, positions, count.toULong(), ctx) + } + } else { + val colorSpace = CGColorSpaceCreateDeviceRGB() + val cgColor = memScoped { + val components = allocArray(4) + components[0] = fillColor.r.toDouble() + components[1] = fillColor.g.toDouble() + components[2] = fillColor.b.toDouble() + components[3] = 1.0 + CGColorCreate(colorSpace, components) + } + val x0 = xPositions.firstOrNull() ?: 0.0 + drawTextAtPosition(ctFont, text, x0, yOffset, cgColor) + CGColorRelease(cgColor) + CGColorSpaceRelease(colorSpace) + } + + CFRelease(ctFont) + CGContextRestoreGState(ctx) + } + + private fun drawTextAtPosition( + ctFont: platform.CoreText.CTFontRef, + text: String, + x: Double, + y: Double, + cgColor: platform.CoreGraphics.CGColorRef?, + ) { + // Create attributed string with font and color + val attrDict = CFDictionaryCreateMutable( + kCFAllocatorDefault, 2, + kCFTypeDictionaryKeyCallBacks.ptr, kCFTypeDictionaryValueCallBacks.ptr, + ) + @Suppress("UNCHECKED_CAST") + CFDictionarySetValue(attrDict, kCTFontAttributeName as CFTypeRef?, ctFont as CFTypeRef?) + if (cgColor != null) { + @Suppress("UNCHECKED_CAST") + CFDictionarySetValue(attrDict, kCTForegroundColorAttributeName as CFTypeRef?, cgColor as CFTypeRef?) + } + + @Suppress("UNCHECKED_CAST") + val textRef = platform.Foundation.CFBridgingRetain(text as platform.Foundation.NSString) as CFStringRef + val attrString = CFAttributedStringCreate( + kCFAllocatorDefault, + textRef, + attrDict, + ) + CFRelease(textRef) + + val line = CTLineCreateWithAttributedString(attrString) + + // Position and draw + CGContextSaveGState(ctx) + CGContextTranslateCTM(ctx, x, y) + CTLineDraw(line, ctx) + CGContextRestoreGState(ctx) + + CFRelease(line) + CFRelease(attrString) + CFRelease(attrDict) + } + + /** + * Maps SVG font-family/weight/style to a Core Text font name. + * Falls back to system font (Helvetica) if no match is found. + */ + private fun resolveFontName(family: String, weight: String?, style: String?): String { + val isBold = weight == "bold" || (weight?.toIntOrNull() ?: 400) >= 700 + val isItalic = style == "italic" || style == "oblique" + return when { + family.equals("Inter", ignoreCase = true) || family.equals("sans-serif", ignoreCase = true) -> { + when { + isBold && isItalic -> "Helvetica-BoldOblique" + isBold -> "Helvetica-Bold" + isItalic -> "Helvetica-Oblique" + else -> "Helvetica" + } + } + family.equals("serif", ignoreCase = true) || family.equals("Times", ignoreCase = true) -> { + when { + isBold && isItalic -> "Times-BoldItalic" + isBold -> "Times-Bold" + isItalic -> "Times-Italic" + else -> "Times-Roman" + } + } + family.equals("monospace", ignoreCase = true) || family.equals("Courier", ignoreCase = true) -> { + when { + isBold && isItalic -> "Courier-BoldOblique" + isBold -> "Courier-Bold" + isItalic -> "Courier-Oblique" + else -> "Courier" + } + } + else -> { + // Try to use the family name directly (may work for system-installed fonts) + when { + isBold && isItalic -> "$family-BoldItalic" + isBold -> "$family-Bold" + isItalic -> "$family-Italic" + else -> family + } + } + } + } + + private fun renderGroup(elem: SvgElement) { + CGContextSaveGState(ctx) + applyTransform(elem); applyOpacity(elem) + applyClipPath(elem) + renderChildren(elem) + CGContextRestoreGState(ctx) + } + + private fun renderUse(elem: SvgElement) { + val href = (elem.attributes["href"] ?: elem.attributes["xlink:href"]) + ?.takeIf { it.isNotEmpty() } ?: return + val def = defs[href.removePrefix("#")] ?: return + + CGContextSaveGState(ctx) + applyTransform(elem) + val x = elem.attr("x")?.toDoubleOrNull() ?: 0.0 + val y = elem.attr("y")?.toDoubleOrNull() ?: 0.0 + if (x != 0.0 || y != 0.0) { + CGContextTranslateCTM(ctx, x, y) + } + renderElement(def) + CGContextRestoreGState(ctx) + } + + private fun renderImage(elem: SvgElement) { + CGContextSaveGState(ctx) + applyTransform(elem); applyOpacity(elem) + + val width = elem.attr("width")?.toDoubleOrNull() ?: return restore() + val height = elem.attr("height")?.toDoubleOrNull() ?: return restore() + + val href = (elem.attributes["href"] ?: elem.attributes["xlink:href"]) + ?.takeIf { it.isNotEmpty() } ?: return restore() + + val cgImage = decodeImage(href) ?: return restore() + + // Counter-flip for image: in Y-flipped coords, flip image back to right-side up + val x = elem.attr("x")?.toDoubleOrNull() ?: 0.0 + val y = elem.attr("y")?.toDoubleOrNull() ?: 0.0 + // Matrix: (1, 0, 0, -1, x, y+height) to flip image right-side up + CGContextConcatCTM(ctx, CGAffineTransformMake(1.0, 0.0, 0.0, -1.0, x, y + height)) + CGContextDrawImage(ctx, CGRectMake(0.0, 0.0, width, height), cgImage) + + CGImageRelease(cgImage) + CGContextRestoreGState(ctx) + } + + private fun decodeImage(href: String): platform.CoreGraphics.CGImageRef? { + if (!href.startsWith("data:image/")) return null + val base64Data = href.substringAfter(",", "") + if (base64Data.isEmpty()) return null + + return try { + // Decode base64 + val nsData = NSData.create( + base64EncodedString = base64Data, + options = 0u, + ) ?: return null + + @Suppress("UNCHECKED_CAST") + val cfData = CFBridgingRetain(nsData) as platform.CoreFoundation.CFDataRef + val imageSource = CGImageSourceCreateWithData(cfData, null) + val result = if (imageSource != null) { + CGImageSourceCreateImageAtIndex(imageSource, 0u, null) + } else { + null + } + if (imageSource != null) CFRelease(imageSource) + CFRelease(cfData) + result + } catch (_: Exception) { + null + } + } + + private fun restore() { + CGContextRestoreGState(ctx) + } + + // -- Fill / stroke -- + + private fun fillAndStroke(elem: SvgElement) { + val fill = elem.attr("fill") + val stroke = elem.attr("stroke") + val hasFill = fill != "none" // SVG default fill is black + val hasStroke = stroke != null && stroke != "none" + + if (hasFill) { + val color = fill?.let { SvgColorParser.parse(it) } + if (color != null) { + CGContextSetRGBFillColor(ctx, color.r.toDouble(), color.g.toDouble(), color.b.toDouble(), 1.0) + } else { + CGContextSetRGBFillColor(ctx, 0.0, 0.0, 0.0, 1.0) // SVG default: black + } + } + + if (hasStroke) { + applyStrokeState(elem) + SvgColorParser.parse(stroke)?.let { c -> + CGContextSetRGBStrokeColor(ctx, c.r.toDouble(), c.g.toDouble(), c.b.toDouble(), 1.0) + } + } + + val evenOdd = elem.attr("fill-rule") == "evenodd" + when { + hasFill && hasStroke -> { + CGContextDrawPath(ctx, if (evenOdd) CGPathDrawingMode.kCGPathEOFillStroke else CGPathDrawingMode.kCGPathFillStroke) + } + hasStroke -> CGContextDrawPath(ctx, CGPathDrawingMode.kCGPathStroke) + hasFill -> { + CGContextDrawPath(ctx, if (evenOdd) CGPathDrawingMode.kCGPathEOFill else CGPathDrawingMode.kCGPathFill) + } + } + } + + private fun applyStrokeState(elem: SvgElement) { + elem.attr("stroke-width")?.toDoubleOrNull()?.let { + CGContextSetLineWidth(ctx, it) + } + + elem.attr("stroke-linecap")?.let { cap -> + CGContextSetLineCap( + ctx, + when (cap) { + "round" -> CGLineCap.kCGLineCapRound + "square" -> CGLineCap.kCGLineCapSquare + else -> CGLineCap.kCGLineCapButt + } + ) + } + + elem.attr("stroke-linejoin")?.let { join -> + CGContextSetLineJoin( + ctx, + when (join) { + "round" -> CGLineJoin.kCGLineJoinRound + "bevel" -> CGLineJoin.kCGLineJoinBevel + else -> CGLineJoin.kCGLineJoinMiter + } + ) + } + + elem.attr("stroke-dasharray")?.takeIf { it != "none" }?.let { dashStr -> + val dashes = dashStr.split(Regex("[,\\s]+")).mapNotNull { it.toDoubleOrNull() } + if (dashes.isNotEmpty()) { + val phase = elem.attr("stroke-dashoffset")?.toDoubleOrNull() ?: 0.0 + memScoped { + val dashArray = allocArray(dashes.size) + for (idx in dashes.indices) { + dashArray[idx] = dashes[idx] + } + CGContextSetLineDash(ctx, phase, dashArray, dashes.size.toULong()) + } + } + } + } + + // -- Transforms -- + + private fun applyTransform(elem: SvgElement) { + val transform = elem.attributes["transform"] ?: return + if (transform.isEmpty()) return + + for (match in TRANSFORM_RE.findAll(transform)) { + val func = match.groupValues[1] + val params = match.groupValues[2] + .split(Regex("[,\\s]+")) + .mapNotNull { it.trim().toDoubleOrNull() } + + when (func) { + "translate" -> { + val tx = params.getOrElse(0) { 0.0 } + val ty = params.getOrElse(1) { 0.0 } + CGContextTranslateCTM(ctx, tx, ty) + } + "scale" -> { + val sx = params.getOrElse(0) { 1.0 } + val sy = params.getOrElse(1) { sx } + CGContextScaleCTM(ctx, sx, sy) + } + "rotate" -> { + val angle = (params.getOrElse(0) { 0.0 }) * PI / 180.0 + if (params.size >= 3) { + val cx = params[1] + val cy = params[2] + CGContextTranslateCTM(ctx, cx, cy) + CGContextConcatCTM(ctx, CGAffineTransformMake(cos(angle), sin(angle), -sin(angle), cos(angle), 0.0, 0.0)) + CGContextTranslateCTM(ctx, -cx, -cy) + } else { + CGContextConcatCTM(ctx, CGAffineTransformMake(cos(angle), sin(angle), -sin(angle), cos(angle), 0.0, 0.0)) + } + } + "matrix" -> { + if (params.size >= 6) { + CGContextConcatCTM( + ctx, + CGAffineTransformMake(params[0], params[1], params[2], params[3], params[4], params[5]) + ) + } + } + "skewX" -> { + val a = (params.getOrElse(0) { 0.0 }) * PI / 180.0 + CGContextConcatCTM(ctx, CGAffineTransformMake(1.0, 0.0, tan(a), 1.0, 0.0, 0.0)) + } + "skewY" -> { + val a = (params.getOrElse(0) { 0.0 }) * PI / 180.0 + CGContextConcatCTM(ctx, CGAffineTransformMake(1.0, tan(a), 0.0, 1.0, 0.0, 0.0)) + } + } + } + } + + // -- Opacity -- + + private fun applyOpacity(elem: SvgElement) { + val opacity = elem.attr("opacity")?.toDoubleOrNull() + val fillOp = elem.attr("fill-opacity")?.toDoubleOrNull() + val strokeOp = elem.attr("stroke-opacity")?.toDoubleOrNull() + if (opacity == null && fillOp == null && strokeOp == null) return + + // Core Graphics doesn't have separate fill/stroke alpha like PDFBox's + // PDExtendedGraphicsState. Use the combined effective opacity via CGContextSetAlpha. + // This approximation is correct when only opacity or only fill-opacity is set. + // For the rare case of different fill-opacity and stroke-opacity, we use + // the lower value (conservative approach). + val effFill = (opacity ?: 1.0) * (fillOp ?: 1.0) + val effStroke = (opacity ?: 1.0) * (strokeOp ?: 1.0) + val combinedAlpha = minOf(effFill, effStroke) + if (combinedAlpha < 1.0) { + CGContextSetAlpha(ctx, combinedAlpha) + } + } + + // -- Clipping -- + + private fun applyClipPath(elem: SvgElement) { + val clipRef = elem.attr("clip-path") ?: return + val clipId = Regex("""url\(#([^)]+)\)""").find(clipRef)?.groupValues?.get(1) ?: return + val clipElem = defs[clipId] ?: return + + CGContextBeginPath(ctx) + + for (child in clipElem.children) { + when (child.name) { + "rect" -> { + val x = child.attributes["x"]?.toDoubleOrNull() ?: 0.0 + val y = child.attributes["y"]?.toDoubleOrNull() ?: 0.0 + val w = child.attributes["width"]?.toDoubleOrNull() ?: continue + val h = child.attributes["height"]?.toDoubleOrNull() ?: continue + val rx = child.attributes["rx"]?.toDoubleOrNull() ?: 0.0 + val ry = child.attributes["ry"]?.toDoubleOrNull() ?: rx + if (rx > 0.0 || ry > 0.0) { + val crx = rx.coerceAtMost(w / 2) + val cry = ry.coerceAtMost(h / 2) + if (crx >= w / 2 && cry >= h / 2) { + // Full-radius = ellipse + drawEllipse(ctx, x + w / 2, y + h / 2, w / 2, h / 2) + } else { + // Partial radius: route through path parser (arcToCubic is proven) + CoreGraphicsPathParser.parse( + buildRoundedRectPathData(x, y, w, h, crx, cry), + ctx, + ) + } + } else { + CGContextAddRect(ctx, CGRectMake(x, y, w, h)) + } + } + "path" -> { + child.attributes["d"]?.takeIf { it.isNotEmpty() }?.let { + CoreGraphicsPathParser.parse(it, ctx) + } + } + "circle" -> { + val cx = child.attributes["cx"]?.toDoubleOrNull() ?: 0.0 + val cy = child.attributes["cy"]?.toDoubleOrNull() ?: 0.0 + val r = child.attributes["r"]?.toDoubleOrNull() ?: continue + drawEllipse(ctx, cx, cy, r, r) + } + "ellipse" -> { + val cx = child.attributes["cx"]?.toDoubleOrNull() ?: 0.0 + val cy = child.attributes["cy"]?.toDoubleOrNull() ?: 0.0 + val rx = child.attributes["rx"]?.toDoubleOrNull() ?: continue + val ry = child.attributes["ry"]?.toDoubleOrNull() ?: continue + drawEllipse(ctx, cx, cy, rx, ry) + } + } + } + CGContextClip(ctx) + } + + companion object { + private val TRANSFORM_RE = + Regex("""(translate|scale|rotate|matrix|skewX|skewY)\(([^)]+)\)""") + } + } +} diff --git a/compose2pdf/src/iosMain/kotlin/com/chrisjenx/compose2pdf/internal/IosPdfRenderer.kt b/compose2pdf/src/iosMain/kotlin/com/chrisjenx/compose2pdf/internal/IosPdfRenderer.kt new file mode 100644 index 0000000..f50cf23 --- /dev/null +++ b/compose2pdf/src/iosMain/kotlin/com/chrisjenx/compose2pdf/internal/IosPdfRenderer.kt @@ -0,0 +1,141 @@ +package com.chrisjenx.compose2pdf.internal + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.Density +import com.chrisjenx.compose2pdf.LocalPdfPageConfig +import com.chrisjenx.compose2pdf.PdfPageConfig +import com.chrisjenx.compose2pdf.PdfPagination + +/** + * iOS-specific PDF renderer using Core Graphics (CGPDFContext). + * + * On iOS, Compose Multiplatform renders via Skiko, which provides access to + * Skia's SVGCanvas. The rendering pipeline is: + * + * ``` + * Compose content -> CanvasLayersComposeScene -> Skia SVGCanvas -> SVG string + * -> SvgParser (NSXMLParser) -> SvgElement tree + * -> CoreGraphicsPdfConverter (CGPDFContext) -> PDF bytes + * ``` + * + * This shares the SVG generation step with the JVM target (via skikoMain's + * [ComposeToSvg]) but uses Core Graphics instead of PDFBox for the SVG-to-PDF + * conversion. + */ +internal object IosPdfRenderer { + + private const val MAX_AUTO_PAGES = 100 + // Compose Constraints max dimension is ~262143px. Stay well under that limit. + private const val MAX_MEASURE_HEIGHT_PX = 200_000 + + fun render( + config: PdfPageConfig, + density: Density, + defaultFontFamily: FontFamily?, + pagination: PdfPagination, + content: @Composable () -> Unit, + ): ByteArray { + return when (pagination) { + PdfPagination.SINGLE_PAGE -> renderSinglePage(config, density, defaultFontFamily, content) + PdfPagination.AUTO -> renderAuto(config, density, defaultFontFamily, content) + } + } + + private fun renderSinglePage( + config: PdfPageConfig, + density: Density, + defaultFontFamily: FontFamily?, + content: @Composable () -> Unit, + ): ByteArray { + val pxW = config.contentWidthPx(density) + val pxH = config.contentHeightPx(density) + + val svg = ComposeToSvg.render(pxW, pxH, density) { + WrapContent(defaultFontFamily, config) { + content() + } + } + return CoreGraphicsPdfConverter.renderSinglePage( + svg = svg, + pageWidthPt = config.width.value, + pageHeightPt = config.height.value, + ) + } + + private fun renderAuto( + config: PdfPageConfig, + density: Density, + defaultFontFamily: FontFamily?, + content: @Composable () -> Unit, + ): ByteArray { + val contentWidthPx = config.contentWidthPx(density) + val contentHeightPx = config.contentHeightPx(density) + + // Measurement pass: determine if content needs pagination + val measuredHeightPx = ComposeToSvg.measureContentHeight( + contentWidthPx, MAX_MEASURE_HEIGHT_PX, density, + ) { + WrapContent(defaultFontFamily, config) { + PaginatedColumn(contentHeightPx = contentHeightPx) { + content() + } + } + } + + // Fits on one page or uses fillMaxHeight -> single-page fallback + if (measuredHeightPx <= contentHeightPx || measuredHeightPx >= MAX_MEASURE_HEIGHT_PX) { + return renderSinglePage(config, density, defaultFontFamily, content) + } + + // Multi-page: render full content and slice + val result = ComposeToSvg.renderWithMeasurement( + contentWidthPx, MAX_MEASURE_HEIGHT_PX, density, + ) { + WrapContent(defaultFontFamily, config) { + PaginatedColumn(contentHeightPx = contentHeightPx) { + content() + } + } + } + + val pageLayout = PageLayout.from(config) + val totalContentHeightPt = result.measuredHeightPx.coerceAtLeast(1) / density.density + + return CoreGraphicsPdfConverter.renderAutoPages( + svg = result.svg, + layout = pageLayout, + totalContentHeightPt = totalContentHeightPt, + density = density.density, + maxPages = MAX_AUTO_PAGES, + ) + } + + // -- Content wrapping -- + + @Composable + private fun WrapContent( + @Suppress("UNUSED_PARAMETER") defaultFontFamily: FontFamily?, + config: PdfPageConfig? = null, + content: @Composable () -> Unit, + ) { + // Note: JVM uses ProvideTextStyle from compose.material to set default font. + // On iOS, compose.material is not available; defaultFontFamily is accepted for + // API parity. When compose.material is added to iosMain dependencies, replace + // this with the same ProvideTextStyle wrapping as the JVM renderer. + CompositionLocalProvider( + LocalPdfPageConfig provides config, + ) { + content() + } + } + + // -- Helpers -- + + private fun PdfPageConfig.contentWidthPx(density: Density): Int = + (contentWidth.value * density.density).toInt() + + private fun PdfPageConfig.contentHeightPx(density: Density): Int = + (contentHeight.value * density.density).toInt() +} diff --git a/compose2pdf/src/iosMain/kotlin/com/chrisjenx/compose2pdf/internal/SvgDocument.kt b/compose2pdf/src/iosMain/kotlin/com/chrisjenx/compose2pdf/internal/SvgDocument.kt new file mode 100644 index 0000000..b22258b --- /dev/null +++ b/compose2pdf/src/iosMain/kotlin/com/chrisjenx/compose2pdf/internal/SvgDocument.kt @@ -0,0 +1,169 @@ +package com.chrisjenx.compose2pdf.internal + +import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.usePinned +import platform.Foundation.NSData +import platform.Foundation.NSXMLParser +import platform.Foundation.NSXMLParserDelegateProtocol +import platform.Foundation.create +import platform.darwin.NSObject + +/** + * A simple DOM model for parsed SVG elements. + * + * Used on iOS as a lightweight alternative to DOM parsers (which aren't available + * in Kotlin/Native). The SVG is parsed via [NSXMLParser] into this tree structure. + */ +internal data class SvgElement( + val name: String, + val attributes: Map = emptyMap(), + val children: List = emptyList(), + val textContent: String = "", +) { + /** Gets an attribute value by name, checking inline `style` first. */ + fun attr(attrName: String): String? { + // Check inline style first (CSS properties override presentation attributes) + val style = attributes["style"] + if (!style.isNullOrEmpty()) { + val styleMap = parseInlineStyle(style) + styleMap[attrName]?.takeIf { it.isNotEmpty() }?.let { return it } + } + return attributes[attrName]?.takeIf { it.isNotEmpty() } + } + + companion object { + private fun parseInlineStyle(style: String): Map { + return style.split(";").mapNotNull { prop -> + val colon = prop.indexOf(':') + if (colon < 0) null + else prop.substring(0, colon).trim() to prop.substring(colon + 1).trim() + }.toMap() + } + } +} + +/** + * Parses an SVG XML string into an [SvgElement] tree using NSXMLParser. + * + * Returns the root SVG element and a map of elements with `id` attributes + * collected from `` and `` elements. + */ +internal object SvgParser { + + data class ParseResult( + val root: SvgElement, + val defs: Map, + ) + + @OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) + fun parse(svg: String): ParseResult { + val bytes = svg.encodeToByteArray() + val nsData = bytes.usePinned { pinned -> + NSData.create( + bytes = pinned.addressOf(0), + length = bytes.size.toULong(), + ) + } + val parser = NSXMLParser(nsData) + val delegate = SvgParserDelegate() + parser.delegate = delegate + parser.shouldProcessNamespaces = true + + if (!parser.parse()) { + val error = parser.parserError + throw IllegalStateException("Failed to parse SVG XML: ${error?.localizedDescription ?: "unknown error"}") + } + + val root = delegate.rootElement + ?: throw IllegalStateException("SVG parsing produced no root element") + + val defs = mutableMapOf() + collectDefs(root, defs) + return ParseResult(root, defs) + } + + private fun collectDefs(parent: SvgElement, defs: MutableMap) { + for (child in parent.children) { + when (child.name) { + "defs" -> { + for (defChild in child.children) { + val id = defChild.attributes["id"] + if (!id.isNullOrEmpty()) defs[id] = defChild + } + } + // Skia emits as siblings of , not inside + "clipPath" -> { + val id = child.attributes["id"] + if (!id.isNullOrEmpty()) defs[id] = child + } + + "g" -> collectDefs(child, defs) + } + } + } +} + +/** + * NSXMLParser delegate that builds an [SvgElement] tree. + * + * Uses a stack-based approach: each `didStartElement` pushes a new builder + * onto the stack, and `didEndElement` pops it and adds it as a child of + * the parent element. + */ +@OptIn(BetaInteropApi::class) +private class SvgParserDelegate : NSObject(), NSXMLParserDelegateProtocol { + + var rootElement: SvgElement? = null + private set + + private val elementStack = mutableListOf() + + private class ElementBuilder( + val name: String, + val attributes: Map, + ) { + val children = mutableListOf() + val textContent = StringBuilder() + } + + override fun parser( + parser: NSXMLParser, + didStartElement: String, + namespaceURI: String?, + qualifiedName: String?, + attributes: Map, + ) { + @Suppress("UNCHECKED_CAST") + val attrs = (attributes as Map) + elementStack.add(ElementBuilder(didStartElement, attrs)) + } + + override fun parser( + parser: NSXMLParser, + didEndElement: String, + namespaceURI: String?, + qualifiedName: String?, + ) { + if (elementStack.isEmpty()) return + val builder = elementStack.removeLast() + val element = SvgElement( + name = builder.name, + attributes = builder.attributes, + children = builder.children.toList(), + textContent = builder.textContent.toString().trim(), + ) + if (elementStack.isEmpty()) { + rootElement = element + } else { + elementStack.last().children.add(element) + } + } + + override fun parser(parser: NSXMLParser, foundCharacters: String) { + if (elementStack.isNotEmpty()) { + elementStack.last().textContent.append(foundCharacters) + } + } +} diff --git a/compose2pdf/src/iosSimulatorArm64Test/kotlin/com/chrisjenx/compose2pdf/IosPdfRenderTest.kt b/compose2pdf/src/iosSimulatorArm64Test/kotlin/com/chrisjenx/compose2pdf/IosPdfRenderTest.kt new file mode 100644 index 0000000..53f799c --- /dev/null +++ b/compose2pdf/src/iosSimulatorArm64Test/kotlin/com/chrisjenx/compose2pdf/IosPdfRenderTest.kt @@ -0,0 +1,97 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class, kotlinx.cinterop.BetaInteropApi::class) + +package com.chrisjenx.compose2pdf + +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Text +import androidx.compose.ui.unit.sp +import com.chrisjenx.compose2pdf.fixtures.sharedFixtures +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.usePinned +import platform.Foundation.NSData +import platform.Foundation.NSFileManager +import platform.Foundation.NSProcessInfo +import platform.Foundation.create +import platform.Foundation.writeToFile +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class IosPdfRenderTest { + + // Output dir for PDFs — picked up by the fidelity test report. + // Uses IOS_PDF_OUTPUT_DIR env var if set, otherwise falls back to tmp. + private val outputDir: String by lazy { + val envDir = NSProcessInfo.processInfo.environment["IOS_PDF_OUTPUT_DIR"] as? String + val dir = envDir ?: "/tmp/compose2pdf-ios-test-output" + NSFileManager.defaultManager.createDirectoryAtPath( + dir, withIntermediateDirectories = true, attributes = null, error = null, + ) + dir + } + + @Test + fun basicRender_producesValidPdf() { + val bytes = renderToPdf { + Text("Hello from iOS!", fontSize = 24.sp) + } + assertValidPdf(bytes, "basicRender") + } + + @Test + fun textWithLetterSpacing_producesValidPdf() { + // Regression test: letter-spacing causes Skia to emit per-glyph x-positions + // in SVG elements. The renderer must handle these without introducing + // visible spacing artifacts (previously each glyph was drawn as a separate CTLine). + val bytes = renderToPdf { + Column { + Text("Normal spacing", fontSize = 16.sp) + Text("Wide spacing", fontSize = 16.sp, letterSpacing = 4.sp) + Text("Tight spacing", fontSize = 16.sp, letterSpacing = (-0.5).sp) + } + } + assertValidPdf(bytes, "textWithLetterSpacing") + savePdf("text-letter-spacing-ios.pdf", bytes) + } + + @Test + fun renderAllSharedFixtures() { + val failures = mutableListOf() + + for (fixture in sharedFixtures) { + try { + val bytes = renderToPdf(config = fixture.config) { + fixture.content() + } + assertTrue(bytes.size > 50, "${fixture.name}: PDF too small (${bytes.size} bytes)") + val header = bytes.sliceArray(0..3).decodeToString() + assertEquals("%PDF", header, "${fixture.name}: not a valid PDF") + savePdf("${fixture.name}-ios.pdf", bytes) + } catch (e: Exception) { + failures.add("${fixture.name}: ${e.message}") + } + } + + if (failures.isNotEmpty()) { + println("\n=== iOS Fixture Failures ===") + failures.forEach { println(" - $it") } + println("============================\n") + } + println("iOS PDFs saved to: $outputDir") + assertTrue(failures.isEmpty(), "${failures.size} fixtures failed:\n${failures.joinToString("\n")}") + } + + private fun savePdf(name: String, bytes: ByteArray) { + bytes.usePinned { pinned -> + val nsData = NSData.create(bytes = pinned.addressOf(0), length = bytes.size.toULong()) + nsData.writeToFile("$outputDir/$name", atomically = true) + } + } + + private fun assertValidPdf(bytes: ByteArray, label: String) { + assertTrue(bytes.size > 50, "$label: PDF too small (${bytes.size} bytes)") + val header = bytes.sliceArray(0..3).decodeToString() + assertEquals("%PDF", header, "$label: not a valid PDF: $header") + println("$label: ${bytes.size} bytes OK") + } +} diff --git a/compose2pdf/src/main/kotlin/com/chrisjenx/compose2pdf/Compose2Pdf.kt b/compose2pdf/src/jvmMain/kotlin/com/chrisjenx/compose2pdf/Compose2Pdf.kt similarity index 98% rename from compose2pdf/src/main/kotlin/com/chrisjenx/compose2pdf/Compose2Pdf.kt rename to compose2pdf/src/jvmMain/kotlin/com/chrisjenx/compose2pdf/Compose2Pdf.kt index 1ccca52..e070df5 100644 --- a/compose2pdf/src/main/kotlin/com/chrisjenx/compose2pdf/Compose2Pdf.kt +++ b/compose2pdf/src/jvmMain/kotlin/com/chrisjenx/compose2pdf/Compose2Pdf.kt @@ -7,11 +7,6 @@ import com.chrisjenx.compose2pdf.internal.PdfRenderer import java.io.ByteArrayOutputStream import java.io.OutputStream -/** - * Exception thrown when PDF rendering fails. - */ -class Compose2PdfException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) - /** * Renders Compose content to a PDF and writes it to [outputStream]. * diff --git a/compose2pdf/src/main/kotlin/com/chrisjenx/compose2pdf/PdfFonts.kt b/compose2pdf/src/jvmMain/kotlin/com/chrisjenx/compose2pdf/PdfFonts.kt similarity index 100% rename from compose2pdf/src/main/kotlin/com/chrisjenx/compose2pdf/PdfFonts.kt rename to compose2pdf/src/jvmMain/kotlin/com/chrisjenx/compose2pdf/PdfFonts.kt diff --git a/compose2pdf/src/main/kotlin/com/chrisjenx/compose2pdf/internal/ComposeToSvg.kt b/compose2pdf/src/jvmMain/kotlin/com/chrisjenx/compose2pdf/internal/ComposeToSvg.kt similarity index 100% rename from compose2pdf/src/main/kotlin/com/chrisjenx/compose2pdf/internal/ComposeToSvg.kt rename to compose2pdf/src/jvmMain/kotlin/com/chrisjenx/compose2pdf/internal/ComposeToSvg.kt diff --git a/compose2pdf/src/main/kotlin/com/chrisjenx/compose2pdf/internal/CoordinateTransform.kt b/compose2pdf/src/jvmMain/kotlin/com/chrisjenx/compose2pdf/internal/CoordinateTransform.kt similarity index 100% rename from compose2pdf/src/main/kotlin/com/chrisjenx/compose2pdf/internal/CoordinateTransform.kt rename to compose2pdf/src/jvmMain/kotlin/com/chrisjenx/compose2pdf/internal/CoordinateTransform.kt diff --git a/compose2pdf/src/main/kotlin/com/chrisjenx/compose2pdf/internal/FontResolver.kt b/compose2pdf/src/jvmMain/kotlin/com/chrisjenx/compose2pdf/internal/FontResolver.kt similarity index 100% rename from compose2pdf/src/main/kotlin/com/chrisjenx/compose2pdf/internal/FontResolver.kt rename to compose2pdf/src/jvmMain/kotlin/com/chrisjenx/compose2pdf/internal/FontResolver.kt diff --git a/compose2pdf/src/main/kotlin/com/chrisjenx/compose2pdf/internal/PdfRenderer.kt b/compose2pdf/src/jvmMain/kotlin/com/chrisjenx/compose2pdf/internal/PdfRenderer.kt similarity index 100% rename from compose2pdf/src/main/kotlin/com/chrisjenx/compose2pdf/internal/PdfRenderer.kt rename to compose2pdf/src/jvmMain/kotlin/com/chrisjenx/compose2pdf/internal/PdfRenderer.kt diff --git a/compose2pdf/src/main/kotlin/com/chrisjenx/compose2pdf/internal/SvgPathParser.kt b/compose2pdf/src/jvmMain/kotlin/com/chrisjenx/compose2pdf/internal/SvgPathParser.kt similarity index 100% rename from compose2pdf/src/main/kotlin/com/chrisjenx/compose2pdf/internal/SvgPathParser.kt rename to compose2pdf/src/jvmMain/kotlin/com/chrisjenx/compose2pdf/internal/SvgPathParser.kt diff --git a/compose2pdf/src/main/kotlin/com/chrisjenx/compose2pdf/internal/SvgShapeRenderer.kt b/compose2pdf/src/jvmMain/kotlin/com/chrisjenx/compose2pdf/internal/SvgShapeRenderer.kt similarity index 100% rename from compose2pdf/src/main/kotlin/com/chrisjenx/compose2pdf/internal/SvgShapeRenderer.kt rename to compose2pdf/src/jvmMain/kotlin/com/chrisjenx/compose2pdf/internal/SvgShapeRenderer.kt diff --git a/compose2pdf/src/main/kotlin/com/chrisjenx/compose2pdf/internal/SvgToPdfConverter.kt b/compose2pdf/src/jvmMain/kotlin/com/chrisjenx/compose2pdf/internal/SvgToPdfConverter.kt similarity index 97% rename from compose2pdf/src/main/kotlin/com/chrisjenx/compose2pdf/internal/SvgToPdfConverter.kt rename to compose2pdf/src/jvmMain/kotlin/com/chrisjenx/compose2pdf/internal/SvgToPdfConverter.kt index fe25623..a07e83f 100644 --- a/compose2pdf/src/main/kotlin/com/chrisjenx/compose2pdf/internal/SvgToPdfConverter.kt +++ b/compose2pdf/src/jvmMain/kotlin/com/chrisjenx/compose2pdf/internal/SvgToPdfConverter.kt @@ -117,7 +117,15 @@ internal object SvgToPdfConverter { } private val documentBuilderFactory: DocumentBuilderFactory by lazy { - DocumentBuilderFactory.newInstance().apply { isNamespaceAware = true } + DocumentBuilderFactory.newInstance().apply { + isNamespaceAware = true + // Defense-in-depth: disable external entities and DTDs. + // This parser only processes internally-generated SVG from Skia, + // but hardening prevents misuse if the API surface changes. + setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) + setFeature("http://xml.org/sax/features/external-general-entities", false) + setFeature("http://xml.org/sax/features/external-parameter-entities", false) + } } private fun parseSvg(svg: String): Pair> { diff --git a/compose2pdf/src/main/resources/fonts/Inter-Bold.ttf b/compose2pdf/src/jvmMain/resources/fonts/Inter-Bold.ttf similarity index 100% rename from compose2pdf/src/main/resources/fonts/Inter-Bold.ttf rename to compose2pdf/src/jvmMain/resources/fonts/Inter-Bold.ttf diff --git a/compose2pdf/src/main/resources/fonts/Inter-BoldItalic.ttf b/compose2pdf/src/jvmMain/resources/fonts/Inter-BoldItalic.ttf similarity index 100% rename from compose2pdf/src/main/resources/fonts/Inter-BoldItalic.ttf rename to compose2pdf/src/jvmMain/resources/fonts/Inter-BoldItalic.ttf diff --git a/compose2pdf/src/main/resources/fonts/Inter-Italic.ttf b/compose2pdf/src/jvmMain/resources/fonts/Inter-Italic.ttf similarity index 100% rename from compose2pdf/src/main/resources/fonts/Inter-Italic.ttf rename to compose2pdf/src/jvmMain/resources/fonts/Inter-Italic.ttf diff --git a/compose2pdf/src/main/resources/fonts/Inter-Regular.ttf b/compose2pdf/src/jvmMain/resources/fonts/Inter-Regular.ttf similarity index 100% rename from compose2pdf/src/main/resources/fonts/Inter-Regular.ttf rename to compose2pdf/src/jvmMain/resources/fonts/Inter-Regular.ttf diff --git a/compose2pdf/src/test/kotlin/com/chrisjenx/compose2pdf/AutoPaginationTest.kt b/compose2pdf/src/jvmTest/kotlin/com/chrisjenx/compose2pdf/AutoPaginationTest.kt similarity index 100% rename from compose2pdf/src/test/kotlin/com/chrisjenx/compose2pdf/AutoPaginationTest.kt rename to compose2pdf/src/jvmTest/kotlin/com/chrisjenx/compose2pdf/AutoPaginationTest.kt diff --git a/compose2pdf/src/test/kotlin/com/chrisjenx/compose2pdf/BasicRenderTest.kt b/compose2pdf/src/jvmTest/kotlin/com/chrisjenx/compose2pdf/BasicRenderTest.kt similarity index 100% rename from compose2pdf/src/test/kotlin/com/chrisjenx/compose2pdf/BasicRenderTest.kt rename to compose2pdf/src/jvmTest/kotlin/com/chrisjenx/compose2pdf/BasicRenderTest.kt diff --git a/compose2pdf/src/test/kotlin/com/chrisjenx/compose2pdf/CoordinateTransformTest.kt b/compose2pdf/src/jvmTest/kotlin/com/chrisjenx/compose2pdf/CoordinateTransformTest.kt similarity index 100% rename from compose2pdf/src/test/kotlin/com/chrisjenx/compose2pdf/CoordinateTransformTest.kt rename to compose2pdf/src/jvmTest/kotlin/com/chrisjenx/compose2pdf/CoordinateTransformTest.kt diff --git a/compose2pdf/src/test/kotlin/com/chrisjenx/compose2pdf/FontResolverTest.kt b/compose2pdf/src/jvmTest/kotlin/com/chrisjenx/compose2pdf/FontResolverTest.kt similarity index 100% rename from compose2pdf/src/test/kotlin/com/chrisjenx/compose2pdf/FontResolverTest.kt rename to compose2pdf/src/jvmTest/kotlin/com/chrisjenx/compose2pdf/FontResolverTest.kt diff --git a/compose2pdf/src/test/kotlin/com/chrisjenx/compose2pdf/PaginatedColumnTest.kt b/compose2pdf/src/jvmTest/kotlin/com/chrisjenx/compose2pdf/PaginatedColumnTest.kt similarity index 100% rename from compose2pdf/src/test/kotlin/com/chrisjenx/compose2pdf/PaginatedColumnTest.kt rename to compose2pdf/src/jvmTest/kotlin/com/chrisjenx/compose2pdf/PaginatedColumnTest.kt diff --git a/compose2pdf/src/test/kotlin/com/chrisjenx/compose2pdf/PdfLinkCollectorTest.kt b/compose2pdf/src/jvmTest/kotlin/com/chrisjenx/compose2pdf/PdfLinkCollectorTest.kt similarity index 100% rename from compose2pdf/src/test/kotlin/com/chrisjenx/compose2pdf/PdfLinkCollectorTest.kt rename to compose2pdf/src/jvmTest/kotlin/com/chrisjenx/compose2pdf/PdfLinkCollectorTest.kt diff --git a/compose2pdf/src/test/kotlin/com/chrisjenx/compose2pdf/PdfPageConfigTest.kt b/compose2pdf/src/jvmTest/kotlin/com/chrisjenx/compose2pdf/PdfPageConfigTest.kt similarity index 100% rename from compose2pdf/src/test/kotlin/com/chrisjenx/compose2pdf/PdfPageConfigTest.kt rename to compose2pdf/src/jvmTest/kotlin/com/chrisjenx/compose2pdf/PdfPageConfigTest.kt diff --git a/compose2pdf/src/test/kotlin/com/chrisjenx/compose2pdf/PdfRoundedCornerShapeTest.kt b/compose2pdf/src/jvmTest/kotlin/com/chrisjenx/compose2pdf/PdfRoundedCornerShapeTest.kt similarity index 100% rename from compose2pdf/src/test/kotlin/com/chrisjenx/compose2pdf/PdfRoundedCornerShapeTest.kt rename to compose2pdf/src/jvmTest/kotlin/com/chrisjenx/compose2pdf/PdfRoundedCornerShapeTest.kt diff --git a/compose2pdf/src/test/kotlin/com/chrisjenx/compose2pdf/SvgColorParserTest.kt b/compose2pdf/src/jvmTest/kotlin/com/chrisjenx/compose2pdf/SvgColorParserTest.kt similarity index 100% rename from compose2pdf/src/test/kotlin/com/chrisjenx/compose2pdf/SvgColorParserTest.kt rename to compose2pdf/src/jvmTest/kotlin/com/chrisjenx/compose2pdf/SvgColorParserTest.kt diff --git a/compose2pdf/src/test/kotlin/com/chrisjenx/compose2pdf/SvgConverterTest.kt b/compose2pdf/src/jvmTest/kotlin/com/chrisjenx/compose2pdf/SvgConverterTest.kt similarity index 100% rename from compose2pdf/src/test/kotlin/com/chrisjenx/compose2pdf/SvgConverterTest.kt rename to compose2pdf/src/jvmTest/kotlin/com/chrisjenx/compose2pdf/SvgConverterTest.kt diff --git a/compose2pdf/src/test/kotlin/com/chrisjenx/compose2pdf/SvgPathParserTest.kt b/compose2pdf/src/jvmTest/kotlin/com/chrisjenx/compose2pdf/SvgPathParserTest.kt similarity index 100% rename from compose2pdf/src/test/kotlin/com/chrisjenx/compose2pdf/SvgPathParserTest.kt rename to compose2pdf/src/jvmTest/kotlin/com/chrisjenx/compose2pdf/SvgPathParserTest.kt diff --git a/compose2pdf/src/test/kotlin/com/chrisjenx/compose2pdf/VectorRenderTest.kt b/compose2pdf/src/jvmTest/kotlin/com/chrisjenx/compose2pdf/VectorRenderTest.kt similarity index 100% rename from compose2pdf/src/test/kotlin/com/chrisjenx/compose2pdf/VectorRenderTest.kt rename to compose2pdf/src/jvmTest/kotlin/com/chrisjenx/compose2pdf/VectorRenderTest.kt diff --git a/docs/_config.yml b/docs/_config.yml index 55eea03..2b9fd26 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -1,6 +1,6 @@ title: compose2pdf -description: "Kotlin JVM library for rendering Compose Desktop content to production-quality PDFs with vector text, embedded fonts, auto-pagination, and server-side streaming." -tagline: "Kotlin PDF library for Compose Desktop" +description: "Kotlin Multiplatform library for rendering Compose content to production-quality PDFs on JVM, Android, and iOS — vector text, embedded fonts, and auto-pagination." +tagline: "Kotlin Multiplatform PDF library" remote_theme: just-the-docs/just-the-docs@v0.10.0 author: Christopher Jenkins lang: en diff --git a/docs/api/fonts.md b/docs/api/fonts.md index 4301312..c3d747c 100644 --- a/docs/api/fonts.md +++ b/docs/api/fonts.md @@ -6,16 +6,31 @@ nav_order: 7 # Fonts -compose2pdf ships with bundled Inter fonts and supports system font resolution. +compose2pdf handles fonts differently on each platform. The JVM target ships with bundled Inter fonts and supports system font resolution. Android and iOS use their platform's native font stack. --- -## InterFontFamily +## Platform behavior + +| | JVM | Android | iOS | +|:--|:----|:--------|:----| +| **Bundled fonts** | `InterFontFamily` (Inter Regular/Bold/Italic/BoldItalic) | -- | -- | +| **System fonts** | Resolved by `FontResolver` (macOS, Linux, Windows directories) | Android font stack via Canvas | Core Text (`CTFont`) | +| **Font embedding** | Yes (automatic subsetting via PDFBox) | Handled by PdfDocument | Handled by Core Graphics | +| **Custom fonts** | `Font(resource = "...")` | Standard Compose font loading | Standard Compose font loading | +| **Fallback** | PDF Standard 14 (Helvetica, Times, Courier) | System default | System default | + +--- + +## InterFontFamily (JVM only) ```kotlin val InterFontFamily: FontFamily ``` +{: .important } +`InterFontFamily` is only available on the JVM target. It is defined in `jvmMain` and is not accessible from Android or iOS code. + Bundled [Inter](https://rsms.me/inter/) font family using static (non-variable) font files. ### Included fonts @@ -27,11 +42,11 @@ Bundled [Inter](https://rsms.me/inter/) font family using static (non-variable) | `FontWeight.Normal` | `FontStyle.Italic` | Inter-Italic.ttf | | `FontWeight.Bold` | `FontStyle.Italic` | Inter-BoldItalic.ttf | -This is the default `defaultFontFamily` for `renderToPdf`. When used, both Compose layout and PDFBox font embedding use the same font files, eliminating any rendering mismatch. +This is the default `defaultFontFamily` for JVM `renderToPdf`. When used, both Compose layout and PDFBox font embedding use the same font files, eliminating any rendering mismatch. --- -## Font resolution chain +## Font resolution chain (JVM) When the SVG-to-PDF converter encounters text, it resolves fonts in this order: @@ -46,17 +61,30 @@ When the SVG-to-PDF converter encounters text, it resolves fonts in this order: ## Using system fonts -Pass `null` to skip the bundled fonts: +Pass `null` to skip the bundled fonts (JVM) or use the platform default (Android/iOS): ```kotlin +// JVM +val pdf = renderToPdf(defaultFontFamily = null) { + Text("System font") +} + +// Android +val pdf = renderToPdf(context = ctx, defaultFontFamily = null) { + Text("System font") +} + +// iOS val pdf = renderToPdf(defaultFontFamily = null) { Text("System font") } ``` +On Android and iOS, `defaultFontFamily` defaults to `null` (system fonts) since `InterFontFamily` is not available. + --- -## Custom fonts +## Custom fonts (JVM) Supply your own `FontFamily`: @@ -71,12 +99,12 @@ val pdf = renderToPdf(defaultFontFamily = myFont) { } ``` -Font files should be in `src/main/resources/`. +Font files should be in `src/jvmMain/resources/` (or `src/main/resources/` for JVM-only projects). --- {: .warning } -**Variable fonts are not supported.** The library's `FontResolver` detects fonts with the `fvar` OpenType table and skips them automatically. PDFBox cannot render variable fonts at specific axis values. Use static `.ttf` or `.otf` files only. +**Variable fonts are not supported (JVM).** The library's `FontResolver` detects fonts with the `fvar` OpenType table and skips them automatically. PDFBox cannot render variable fonts at specific axis values. Use static `.ttf` or `.otf` files only. --- diff --git a/docs/api/pdf-link.md b/docs/api/pdf-link.md index c68e92b..512d573 100644 --- a/docs/api/pdf-link.md +++ b/docs/api/pdf-link.md @@ -71,6 +71,18 @@ PdfLink(href = "mailto:hello@example.com") { --- +## Platform support + +| Platform | Status | Notes | +|:---------|:-------|:------| +| **JVM** | Full support | PDF link annotations added via PDFBox `PDAnnotationLink` | +| **Android** | Not supported | `android.graphics.pdf.PdfDocument` has no annotation API. `PdfLink` is a no-op (content renders, but no clickable link in the PDF) | +| **iOS** | Not supported | Core Graphics PDF context does not write link annotations. `PdfLink` is a no-op | + +The `PdfLink` composable is defined in `commonMain` and is safe to use in shared code — it simply has no effect on platforms that don't support PDF annotations. + +--- + ## See also - [Usage: Links]({{ site.baseurl }}/usage/links) -- All link patterns with examples diff --git a/docs/api/render-mode.md b/docs/api/render-mode.md index 407ff60..237f1d5 100644 --- a/docs/api/render-mode.md +++ b/docs/api/render-mode.md @@ -15,6 +15,9 @@ enum class RenderMode { } ``` +{: .important } +The `RenderMode` parameter is only accepted by the **JVM** `renderToPdf` API. Android always produces vector output via `android.graphics.pdf.PdfDocument`'s Skia-backed Canvas. iOS always produces vector output via Core Graphics. The enum is defined in `commonMain` but is not a parameter on Android or iOS. + --- ## Values @@ -33,7 +36,7 @@ The rendering pipeline: Compose -> ImageComposeScene -> BufferedImage -> PDFBox --- -## Usage +## Usage (JVM only) ```kotlin // Vector (default) @@ -47,4 +50,4 @@ val pdf = renderToPdf(mode = RenderMode.RASTER, density = Density(3f)) { content ## See also -- [Usage: Vector vs Raster]({{ site.baseurl }}/usage/vector-vs-raster) -- Detailed comparison and decision guide +- [Usage: Vector vs Raster]({{ site.baseurl }}/usage/vector-vs-raster) -- Detailed comparison and decision guide (JVM) diff --git a/docs/api/render-to-pdf.md b/docs/api/render-to-pdf.md index 903ff62..47d2f32 100644 --- a/docs/api/render-to-pdf.md +++ b/docs/api/render-to-pdf.md @@ -6,11 +6,27 @@ nav_order: 1 # renderToPdf -The primary entry point for PDF generation. Four overloads: auto-paginating and manual multi-page, each with `ByteArray` and `OutputStream` variants. +The primary entry point for PDF generation. Platform-specific overloads are available on JVM, Android, and iOS. --- -## Auto-paginating (default) +## Platform comparison + +| Feature | JVM | Android | iOS | +|:--------|:---:|:-------:|:---:| +| `ByteArray` return | Yes | Yes | Yes | +| `OutputStream` streaming | Yes | Yes | -- | +| Multi-page (manual) | Yes | -- | -- | +| `RenderMode` parameter | Yes | -- | -- | +| `InterFontFamily` default | Yes | -- | -- | +| `suspend` | No | Yes | No | +| `Context` parameter | No | Yes | No | + +--- + +## JVM + +### Auto-paginating (default) ```kotlin fun renderToPdf( @@ -27,7 +43,7 @@ Renders Compose content to a PDF, automatically splitting across pages when cont With `PdfPagination.AUTO` (the default), direct children of `content` are treated as "keep-together" units — if a child would straddle a page boundary, it is pushed to the next page. A single child taller than a page flows continuously across pages. -### Parameters +#### Parameters | Parameter | Type | Default | Description | |:----------|:-----|:--------|:------------| @@ -38,25 +54,24 @@ With `PdfPagination.AUTO` (the default), direct children of `content` are treate | `pagination` | `PdfPagination` | `PdfPagination.AUTO` | Controls page splitting. `AUTO` automatically paginates. `SINGLE_PAGE` clips to one page | | `content` | `@Composable () -> Unit` | -- | The composable content to render | -### Returns +#### Returns `ByteArray` -- a valid PDF document (one or more pages). -### Throws +#### Throws | Exception | When | |:----------|:-----| | [`Compose2PdfException`]({{ site.baseurl }}/api/exceptions) | Rendering fails (wraps the underlying cause) | | `IllegalArgumentException` | Precondition failures (not wrapped) | -### Example +#### Example ```kotlin val pdfBytes = renderToPdf( config = PdfPageConfig.LetterWithMargins, mode = RenderMode.VECTOR, ) { - // Direct children are "keep-together" units ReportHeader() DataTable(items) // kept together on one page SummarySection() // pushed to next page if needed @@ -66,7 +81,7 @@ File("report.pdf").writeBytes(pdfBytes) --- -## Auto-paginating (OutputStream) +### Auto-paginating (OutputStream) ```kotlin fun renderToPdf( @@ -84,7 +99,7 @@ Streaming variant -- writes the PDF directly to `outputStream` without creating Parameters and behavior are identical to the `ByteArray` variant above. -### Example +#### Example ```kotlin // Ktor @@ -102,7 +117,7 @@ FileOutputStream("report.pdf").use { out -> --- -## Multi-page +### Multi-page ```kotlin fun renderToPdf( @@ -117,7 +132,7 @@ fun renderToPdf( Renders multiple pages of Compose content to a single PDF. The page count must be known upfront. -### Parameters +#### Parameters | Parameter | Type | Default | Description | |:----------|:-----|:--------|:------------| @@ -128,18 +143,18 @@ Renders multiple pages of Compose content to a single PDF. The page count must b | `defaultFontFamily` | `FontFamily?` | [`InterFontFamily`]({{ site.baseurl }}/api/fonts) | Default font family | | `content` | `@Composable (pageIndex: Int) -> Unit` | -- | Content for each page. Receives the **zero-based** page index | -### Returns +#### Returns `ByteArray` -- a valid multi-page PDF document. -### Throws +#### Throws | Exception | When | |:----------|:-----| | [`Compose2PdfException`]({{ site.baseurl }}/api/exceptions) | Rendering fails | | `IllegalArgumentException` | `pages` is not positive | -### Example +#### Example ```kotlin val totalPages = 3 @@ -160,7 +175,7 @@ val pdfBytes = renderToPdf( --- -## Multi-page (OutputStream) +### Multi-page (OutputStream) ```kotlin fun renderToPdf( @@ -178,12 +193,103 @@ Streaming variant of the multi-page API. Writes the PDF directly to `outputStrea Parameters and behavior are identical to the `ByteArray` multi-page variant above. -### Example +--- + +## Android + +{: .note } +The Android API requires a `Context` parameter and is `suspend` -- call it from a coroutine scope. Link annotations (`PdfLink`) are not supported on Android because `android.graphics.pdf.PdfDocument` does not expose annotation APIs. + +### Auto-paginating (ByteArray) ```kotlin -call.respondOutputStream(ContentType.Application.Pdf) { - renderToPdf(this, pages = 3, config = PdfPageConfig.A4WithMargins) { pageIndex -> - PageContent(pageIndex) +suspend fun renderToPdf( + context: Context, + config: PdfPageConfig = PdfPageConfig.A4, + density: Density = Density(2f), + defaultFontFamily: FontFamily? = null, + pagination: PdfPagination = PdfPagination.AUTO, + content: @Composable () -> Unit, +): ByteArray +``` + +#### Parameters + +| Parameter | Type | Default | Description | +|:----------|:-----|:--------|:------------| +| `context` | `Context` | -- | Android context. Any Context works (not just Activity) | +| `config` | [`PdfPageConfig`]({{ site.baseurl }}/api/pdf-page-config) | `PdfPageConfig.A4` | Page size and margins | +| `density` | `Density` | `Density(2f)` | Pixel resolution for Compose layout | +| `defaultFontFamily` | `FontFamily?` | `null` | Default font family. Pass `null` for system fonts | +| `pagination` | `PdfPagination` | `PdfPagination.AUTO` | Controls page splitting | +| `content` | `@Composable () -> Unit` | -- | The composable content to render | + +#### Example + +```kotlin +val pdfBytes = renderToPdf( + context = applicationContext, + config = PdfPageConfig.LetterWithMargins, +) { + Column(Modifier.fillMaxSize().padding(24.dp)) { + Text("Invoice from Android", fontSize = 24.sp) + } +} +``` + +### Auto-paginating (OutputStream) + +```kotlin +suspend fun renderToPdf( + context: Context, + outputStream: OutputStream, + config: PdfPageConfig = PdfPageConfig.A4, + density: Density = Density(2f), + defaultFontFamily: FontFamily? = null, + pagination: PdfPagination = PdfPagination.AUTO, + content: @Composable () -> Unit, +) +``` + +Streaming variant -- writes the PDF directly to `outputStream`. The stream is **not closed** by this function. + +--- + +## iOS + +{: .note } +The iOS API is synchronous and returns `ByteArray` only. There is no `OutputStream` streaming variant or manual multi-page API. Link annotations (`PdfLink`) are not currently supported on iOS. + +### Auto-paginating + +```kotlin +fun renderToPdf( + config: PdfPageConfig = PdfPageConfig.A4, + density: Density = Density(2f), + defaultFontFamily: FontFamily? = null, + pagination: PdfPagination = PdfPagination.AUTO, + content: @Composable () -> Unit, +): ByteArray +``` + +#### Parameters + +| Parameter | Type | Default | Description | +|:----------|:-----|:--------|:------------| +| `config` | [`PdfPageConfig`]({{ site.baseurl }}/api/pdf-page-config) | `PdfPageConfig.A4` | Page size and margins | +| `density` | `Density` | `Density(2f)` | Pixel resolution for Compose layout | +| `defaultFontFamily` | `FontFamily?` | `null` | Default font family. Pass `null` for system fonts | +| `pagination` | `PdfPagination` | `PdfPagination.AUTO` | Controls page splitting | +| `content` | `@Composable () -> Unit` | -- | The composable content to render | + +#### Example + +```kotlin +val pdfBytes = renderToPdf( + config = PdfPageConfig.A4WithMargins, +) { + Column(Modifier.fillMaxSize().padding(24.dp)) { + Text("Invoice from iOS", fontSize = 24.sp) } } ``` @@ -192,7 +298,7 @@ call.respondOutputStream(ContentType.Application.Pdf) { ## Thread safety -Both overloads are **not thread-safe**. Concurrent calls should be serialized externally (e.g., via a `Mutex` or single-threaded `Dispatchers`). +All overloads on all platforms are **not thread-safe**. Concurrent calls should be serialized externally (e.g., via a `Mutex` or single-threaded `Dispatchers`). --- @@ -201,4 +307,4 @@ Both overloads are **not thread-safe**. Concurrent calls should be serialized ex - [Usage: Single Page]({{ site.baseurl }}/usage/single-page) - [Usage: Multi-page]({{ site.baseurl }}/usage/multi-page) - [Usage: Auto-pagination]({{ site.baseurl }}/usage/auto-pagination) -- [Guide: Server-side & Ktor]({{ site.baseurl }}/guides/server-side) +- [Guide: Server-side & Ktor]({{ site.baseurl }}/guides/server-side) (JVM) diff --git a/docs/changelog.md b/docs/changelog.md index 14a946a..bef8b76 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -11,6 +11,35 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). --- +## 2.0.0 + +### Added + +- **Kotlin Multiplatform** -- compose2pdf now targets JVM, Android, and iOS +- **Android support** -- renders PDFs via `android.graphics.pdf.PdfDocument` (zero external dependencies). Suspend API with `Context` parameter. Always produces vector output +- **iOS support** -- renders PDFs via Core Graphics (`CGPDFContext`). Supports iosArm64, iosX64, and iosSimulatorArm64 targets +- **Auto-pagination on all platforms** -- content automatically flows across pages on JVM, Android, and iOS +- **`test-fixtures` module** -- shared multiplatform test utilities for JVM, Android, and iOS + +### Changed + +- **Artifact structure** -- the single `compose2pdf` JVM artifact is now a KMP metadata module. Platform-specific artifacts are `compose2pdf-jvm`, `compose2pdf-android`, and `compose2pdf-iosarm64`/`iosx64`/`iossimulatorarm64`. Existing consumers using `implementation("com.chrisjenx:compose2pdf:...")` continue to work via Gradle Module Metadata (Gradle 6.0+) +- **CI** -- added Android SDK setup, iOS simulator tests on macOS, and updated publish workflows to run on macOS for full KMP target support +- **Security** -- added XXE/DTD prevention to DocumentBuilderFactory in SVG parser (defense-in-depth) + +### Platform limitations + +| Feature | JVM | Android | iOS | +|:--------|:---:|:-------:|:---:| +| `RenderMode` (VECTOR/RASTER) | Yes | -- (always vector) | -- (always vector) | +| `InterFontFamily` | Yes | -- | -- | +| `PdfLink` annotations | Yes | -- | -- | +| `OutputStream` streaming | Yes | Yes | -- | +| Multi-page (manual) | Yes | -- | -- | +| Synchronous API | Yes | -- (`suspend` only) | Yes | + +--- + ## 1.0.0 Initial public release. diff --git a/docs/compatibility.md b/docs/compatibility.md index 2d46980..86e4b0f 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -24,7 +24,7 @@ This table is auto-updated weekly by CI. The compatibility workflow discovers th --- -## JDK versions +## JDK versions (JVM target) | JDK | Status | |:----|:-------| @@ -37,24 +37,58 @@ JDK 17 is the minimum — enforced by `jvmToolchain(17)` in the build. Later JDK ## Platform support +### JVM/Desktop + | Platform | Status | Notes | |:---------|:-------|:------| | **macOS** (arm64, x64) | CI tested | Full support | | **Linux** (x64) | CI tested | Requires `xvfb-run` for headless Compose rendering | | **Windows** (x64) | Supported | Not CI tested, but Compose Desktop and PDFBox both support Windows | -> compose2pdf targets **Compose Desktop** (JVM). Android, iOS, and Compose for Web are not supported. +### Android + +| Requirement | Value | +|:------------|:------| +| **minSdk** | 24 (Android 7.0) | +| **compileSdk** | 35 | +| **API type** | `suspend fun` (requires coroutine scope) | +| **Context** | Required (any Context, not just Activity) | + +### iOS + +| Target | Status | +|:-------|:-------| +| **iosArm64** | Supported (device) | +| **iosX64** | Supported (Intel simulator) | +| **iosSimulatorArm64** | CI tested (Apple Silicon simulator) | + +--- + +## Platform feature matrix + +| Feature | JVM | Android | iOS | +|:--------|:---:|:-------:|:---:| +| Vector output | Yes | Yes | Yes | +| Raster output (`RenderMode.RASTER`) | Yes | -- | -- | +| Auto-pagination | Yes | Yes | Yes | +| Multi-page (manual) | Yes | -- | -- | +| `OutputStream` streaming | Yes | Yes | -- | +| `PdfLink` annotations | Yes | -- | -- | +| `InterFontFamily` (bundled Inter) | Yes | -- | -- | +| `RenderMode` parameter | Yes | -- (always vector) | -- (always vector) | +| Synchronous API | Yes | -- (`suspend` only) | Yes | --- ## Dependencies -| Dependency | Version | Purpose | -|:-----------|:--------|:--------| -| Kotlin | 2.3.20 | Language | -| Compose Multiplatform | 1.9+ | UI framework | -| Apache PDFBox | 3.0.7 | PDF generation | -| Kotlinx Coroutines | 1.10.2 | Compose runtime dependency | +| Dependency | Version | Platforms | Purpose | +|:-----------|:--------|:----------|:--------| +| Kotlin | 2.3.20 | All | Language | +| Compose Multiplatform | 1.9+ | All | UI framework | +| Apache PDFBox | 3.0.7 | JVM only | PDF generation | +| Kotlinx Coroutines | 1.10.2 | All | Compose runtime dependency | +| Android Gradle Plugin | 8.9.3 | Android | Android build toolchain | --- diff --git a/docs/getting-started.md b/docs/getting-started.md index 5064473..2c5954c 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -11,17 +11,30 @@ Generate your first PDF from Compose in under 5 minutes. ## Prerequisites -- **JDK 17** or later -- A **Compose Desktop** project (Compose Multiplatform) +- A **Compose Multiplatform** project (Desktop, Android, or iOS) - **Gradle** build system +- **JDK 17+** (for JVM/Desktop targets) +- **Android minSdk 24+** (for Android targets) -If you don't have a Compose Desktop project yet, follow the [JetBrains Compose Multiplatform getting started guide](https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-multiplatform-getting-started.html). +If you don't have a Compose Multiplatform project yet, follow the [JetBrains Compose Multiplatform getting started guide](https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-multiplatform-getting-started.html). --- ## Add the dependency -### Gradle (Kotlin DSL) +### Kotlin Multiplatform + +```kotlin +kotlin { + sourceSets { + commonMain.dependencies { + implementation("com.chrisjenx:compose2pdf:1.0.0") + } + } +} +``` + +### JVM or Android only (Gradle Kotlin DSL) ```kotlin dependencies { @@ -29,7 +42,7 @@ dependencies { } ``` -### Gradle (Groovy DSL) +### Gradle Groovy DSL ```groovy dependencies { @@ -43,6 +56,8 @@ The library is published to **Maven Central** -- no additional repository config ## Your first PDF +### JVM/Desktop + Create a Kotlin file (e.g., `GeneratePdf.kt`) and add: ```kotlin @@ -77,11 +92,48 @@ Run it, and open `hello.pdf` in any PDF viewer. You'll see: - "Generated with compose2pdf" in smaller gray text - Selectable, vector text on an A4 page +### Android + +```kotlin +import com.chrisjenx.compose2pdf.renderToPdf +import com.chrisjenx.compose2pdf.PdfPageConfig + +// In a ViewModel or coroutine scope: +val pdfBytes = renderToPdf( + context = applicationContext, + config = PdfPageConfig.A4WithMargins, +) { + Column(Modifier.fillMaxSize().padding(32.dp)) { + Text("Hello from Android!", fontSize = 28.sp) + } +} +// Save or share pdfBytes +``` + +{: .note } +The Android API is `suspend` -- call it from a coroutine scope. It requires a `Context` parameter (any Context works, not just Activity). + +### iOS + +```kotlin +import com.chrisjenx.compose2pdf.renderToPdf +import com.chrisjenx.compose2pdf.PdfPageConfig + +val pdfBytes = renderToPdf( + config = PdfPageConfig.A4WithMargins, +) { + Column(Modifier.fillMaxSize().padding(32.dp)) { + Text("Hello from iOS!", fontSize = 28.sp) + } +} +// Use pdfBytes with UIKit or share +``` + ### What just happened? 1. `renderToPdf { ... }` takes a `@Composable` lambda -- the same kind you write for Compose UI -2. The library renders your composable through Skia's SVGCanvas, converts the SVG to PDF vector commands via PDFBox, and returns the PDF as a `ByteArray` -3. Default settings: A4 page, no margins, vector mode, Inter font, 2x density +2. The library renders your composable through a platform-native PDF pipeline and returns the PDF as a `ByteArray` +3. Default settings: A4 page, no margins, vector mode, 2x density --- @@ -91,7 +143,6 @@ Run it, and open `hello.pdf` in any PDF viewer. You'll see: val pdf = renderToPdf( config = PdfPageConfig.LetterWithMargins, // US Letter with 1" margins density = Density(2f), // 2x pixel density - mode = RenderMode.VECTOR, // Vector output (default) ) { Column(Modifier.fillMaxSize()) { Text("Custom configuration", fontSize = 20.sp) @@ -99,6 +150,9 @@ val pdf = renderToPdf( } ``` +{: .note } +JVM additionally supports `mode = RenderMode.VECTOR` (default) or `RenderMode.RASTER`, and `defaultFontFamily = InterFontFamily` (bundled Inter fonts). These parameters are not available on Android or iOS. + --- ## Next steps diff --git a/docs/guides/architecture.md b/docs/guides/architecture.md index 15d8efb..5afe3f4 100644 --- a/docs/guides/architecture.md +++ b/docs/guides/architecture.md @@ -6,11 +6,11 @@ nav_order: 3 # Architecture -How compose2pdf converts Compose content to PDF under the hood. +How compose2pdf converts Compose content to PDF under the hood. Each platform uses a native PDF pipeline. --- -## Pipeline overview +## Pipeline overview (JVM) ``` ┌─────────────────────────────────────────────────┐ @@ -45,7 +45,60 @@ How compose2pdf converts Compose content to PDF under the hood. --- -## Vector mode in detail +## Android pipeline + +``` +┌───────────────────────────────────────────────┐ +│ renderToPdf() │ +│ (suspend, Context) │ +│ │ +│ OffScreenComposeRenderer │ +│ → Headless virtual display │ +│ → Compose content rendered off-screen │ +│ → View.draw(canvas) │ +│ ↓ │ +│ android.graphics.pdf.PdfDocument │ +│ → Skia-backed Canvas │ +│ → Vector PDF (selectable text, paths) │ +│ ↓ │ +│ PdfDocument.writeTo(outputStream) │ +│ → PDF bytes │ +└───────────────────────────────────────────────┘ +``` + +Android uses the platform's native `android.graphics.pdf.PdfDocument` API (zero external dependencies). Compose content is rendered to an off-screen virtual display via `OffScreenComposeRenderer`, then drawn directly onto PdfDocument's Skia-backed Canvas. This produces vector output with selectable text and resolution-independent paths. + +{: .note } +The Android API is `suspend` because off-screen Compose rendering requires the main thread and asynchronous composition. Link annotations (`PdfLink`) are not supported because `android.graphics.pdf.PdfDocument` does not expose annotation APIs. + +--- + +## iOS pipeline + +``` +┌───────────────────────────────────────────────┐ +│ renderToPdf() │ +│ │ +│ CanvasLayersComposeScene │ +│ → Skia PictureRecorder → SVGCanvas │ +│ → SVG string │ +│ ↓ │ +│ NSXMLParser → SvgElement tree │ +│ ↓ │ +│ CoreGraphicsPdfConverter │ +│ ├── CoreGraphicsPathParser (SVG → CGPath) │ +│ ├── CTFontDrawGlyphs (per-glyph text) │ +│ └── CGPDFContext │ +│ ↓ │ +│ NSMutableData → ByteArray │ +└───────────────────────────────────────────────┘ +``` + +iOS uses Skia SVGCanvas (via Skiko) to convert Compose content to SVG, then renders the SVG to PDF using Core Graphics (`CGPDFContext`). SVG parsing uses `NSXMLParser` (not javax.xml), and text is rendered with `CTFontDrawGlyphs` for accurate per-glyph positioning. + +--- + +## JVM vector mode in detail ### Step 1: Compose to SVG @@ -75,7 +128,7 @@ SVG uses a Y-down coordinate system (origin at top-left). PDF uses Y-up (origin --- -## Raster mode in detail +## JVM raster mode in detail `ImageComposeScene` renders the composable to a Skia bitmap at the configured density. The bitmap is converted to a `BufferedImage` and embedded as a lossless PDF image via PDFBox's `LosslessFactory`. @@ -119,6 +172,8 @@ PDFBox's `PDType0Font.load()` automatically subsets embedded fonts -- only the g ## Key implementation files +### JVM + | File | Responsibility | |:-----|:--------------| | `PdfRenderer.kt` | Orchestrates vector/raster pipelines | @@ -130,6 +185,31 @@ PDFBox's `PDType0Font.load()` automatically subsets embedded fonts -- only the g | `CoordinateTransform.kt` | SVG Y-down <-> PDF Y-up conversion | | `FontResolver.kt` | Font family/weight/style -> PDFBox font | +### Android + +| File | Responsibility | +|:-----|:--------------| +| `AndroidPdfRenderer.kt` | Orchestrates rendering via PdfDocument | +| `OffScreenComposeRenderer.kt` | Headless Compose rendering via virtual display | + +### iOS + +| File | Responsibility | +|:-----|:--------------| +| `IosPdfRenderer.kt` | Orchestrates SVG -> Core Graphics pipeline | +| `ComposeToSvg.kt` (iOS) | Compose content -> SVG string via Skia | +| `CoreGraphicsPdfConverter.kt` | SVG -> PDF via CGPDFContext | +| `CoreGraphicsPathParser.kt` | SVG path data -> CGPath commands | +| `SvgDocument.kt` | SVG parsing via NSXMLParser | + +### Common + +| File | Responsibility | +|:-----|:--------------| +| `PaginatedColumn.kt` | Smart page-break layout | +| `PageLayout.kt` | Page layout utilities | +| `PdfLink.kt` | Link annotation composable + collector | + --- ## Future: native Skia PDF backend diff --git a/docs/guides/server-side.md b/docs/guides/server-side.md index 9455102..79e4e24 100644 --- a/docs/guides/server-side.md +++ b/docs/guides/server-side.md @@ -6,6 +6,9 @@ nav_order: 5 # Server-side Integration +{: .note } +This guide applies to **JVM only**. Server-side PDF generation requires the JVM target with JDK 17+ and AWT support. + compose2pdf works well for server-side PDF generation. The `OutputStream` overloads let you stream PDFs directly to HTTP responses without buffering the entire document as a `ByteArray`. --- diff --git a/docs/guides/supported-features.md b/docs/guides/supported-features.md index ca659b1..07702a6 100644 --- a/docs/guides/supported-features.md +++ b/docs/guides/supported-features.md @@ -6,7 +6,10 @@ nav_order: 4 # Supported Compose Features -Comprehensive matrix of Compose feature support in compose2pdf's vector and raster rendering modes. +Comprehensive matrix of Compose feature support across platforms and rendering modes. + +{: .note } +The Vector/Raster columns below apply to **JVM only**. Android and iOS always produce vector output. Layout, text, shapes, and images are supported on all platforms. --- @@ -88,13 +91,13 @@ Comprehensive matrix of Compose feature support in compose2pdf's vector and rast | Feature | Vector | Raster | Notes | |:--------|:------:|:------:|:------| -| `PdfLink` annotations | Full | Full | Clickable URLs in PDF | -| `PdfRoundedCornerShape` | Full | Full | PDF-safe asymmetric corners | -| `InterFontFamily` | Full | N/A | Font embedding (vector only) | -| Multi-page documents | Full | Full | Manual pagination with known page count | -| Auto-pagination | Full | Full | Content automatically flows across pages (up to 100) | -| `OutputStream` streaming | Full | Full | Write PDF directly to a stream (Ktor, servlets, files) | -| `PdfPagination.SINGLE_PAGE` | Full | Full | Clip content to one page (opt-in) | +| `PdfLink` annotations | Full | Full | JVM only — clickable URLs in PDF | +| `PdfRoundedCornerShape` | Full | Full | All platforms | +| `InterFontFamily` | Full | N/A | JVM only — font embedding (vector only) | +| Multi-page documents | Full | Full | JVM only — manual pagination with known page count | +| Auto-pagination | Full | Full | All platforms — content flows across pages (up to 100) | +| `OutputStream` streaming | Full | Full | JVM and Android — write PDF directly to a stream | +| `PdfPagination.SINGLE_PAGE` | Full | Full | All platforms — clip content to one page (opt-in) | --- diff --git a/docs/index.md b/docs/index.md index aeffae1..27c4938 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,12 +2,12 @@ title: Home layout: home nav_order: 1 -description: "Kotlin JVM library for PDF generation from Compose Desktop — vector text, embedded fonts, auto-pagination, and server-side streaming with Ktor." +description: "Kotlin Multiplatform library for PDF generation from Compose — vector text, embedded fonts, and auto-pagination on JVM, Android, and iOS." --- -# compose2pdf — Kotlin PDF Library for Compose Desktop +# compose2pdf — Kotlin Multiplatform PDF Library -**Generate production-quality PDFs from Compose Desktop content** — vector text, embedded fonts, auto-pagination, and server-side streaming. A Kotlin JVM library that turns your `@Composable` functions into PDF documents. +**Generate production-quality PDFs from Compose content on JVM/Desktop, Android, and iOS** — vector text, embedded fonts, and auto-pagination. A Kotlin Multiplatform library that turns your `@Composable` functions into PDF documents. {: .fs-6 .fw-300 } [Get Started]({{ site.baseurl }}/getting-started){: .btn .btn-primary .fs-5 .mb-4 .mb-md-0 .mr-2 } @@ -53,15 +53,19 @@ That's it. `renderToPdf` takes a `@Composable` lambda and returns a `ByteArray`. Use the same Compose layout primitives you already know -- `Column`, `Row`, `Box`, `Text`, `Canvas` -- and get a production-quality PDF. +### Multiplatform + +Works on JVM/Desktop, Android, and iOS. Each platform uses a native PDF pipeline for best performance and output quality. + ### Vector PDFs -Text is selectable, scales to any zoom level, and produces small files. Powered by Skia's SVGCanvas and Apache PDFBox. +Text is selectable, scales to any zoom level, and produces small files. Powered by platform-native PDF engines (PDFBox on JVM, PdfDocument on Android, Core Graphics on iOS). -### Raster Fallback +### Raster Fallback (JVM) When you need pixel-perfect rendering -- complex gradients, visual effects, or exact bitmap output -- switch to raster mode with one parameter. -### Font Embedding +### Font Embedding (JVM) Ships with bundled Inter fonts (Regular, Bold, Italic, BoldItalic). Fonts are automatically subsetted and embedded so PDFs look the same everywhere. @@ -73,24 +77,26 @@ No new DSL to learn. Write `@Composable` functions, pass them to `renderToPdf()` ## Features at a Glance -| Feature | Description | -|:--------|:------------| -| **Vector output** | Selectable text, crisp at any zoom, small file size | -| **Raster fallback** | Pixel-perfect rendering as an embedded image | -| **Font embedding** | Bundled Inter fonts or system font resolution with automatic subsetting | -| **Link annotations** | Clickable URLs in the PDF via `PdfLink` | -| **Auto-pagination** | Content automatically flows across pages; elements kept together at boundaries | -| **Multi-page** | Render multiple pages in a single PDF (automatic or manual) | -| **Streaming output** | Write PDFs directly to an `OutputStream` for Ktor, servlets, or files | -| **Page presets** | A4, Letter, A3 with configurable margins and landscape support | -| **Shapes** | Backgrounds, borders, clips, rounded corners, Canvas drawing | -| **Images** | Embed bitmap images with clipping and layout | +| Feature | JVM | Android | iOS | +|:--------|:----|:--------|:----| +| **Vector output** | Yes (VECTOR/RASTER modes) | Yes (always vector) | Yes (always vector) | +| **Auto-pagination** | Yes | Yes | Yes | +| **Multi-page (manual)** | Yes | -- | -- | +| **Font embedding** | Bundled Inter + system fonts | System fonts | System fonts | +| **Link annotations** | Yes (`PdfLink`) | -- | -- | +| **Streaming output** | `OutputStream` | `OutputStream` | -- | +| **Page presets** | A4, Letter, A3 + margins + landscape | Same | Same | +| **Shapes & images** | Full support | Full support | Full support | --- ## How it works -compose2pdf renders your Compose content through a **Skia SVGCanvas → Apache PDFBox** pipeline. Compose Desktop's layout engine runs your composables, Skia captures the draw calls as SVG, and compose2pdf converts that to vector PDF commands with embedded fonts and link annotations. +Each platform uses a native PDF pipeline: + +- **JVM**: Compose content is rendered through Skia SVGCanvas, producing SVG that is converted to PDF vector commands via Apache PDFBox with embedded fonts and link annotations. +- **Android**: Compose content is rendered off-screen via a headless virtual display, drawn directly onto `android.graphics.pdf.PdfDocument`'s Skia-backed Canvas. +- **iOS**: Compose content is rendered through Skia SVGCanvas, with SVG parsed via NSXMLParser and converted to PDF via Core Graphics (`CGPDFContext`). {: .note } **Want native PDF output from Skia?** [Skiko PR #775](https://github.com/JetBrains/skiko/pull/775) proposes adding a direct PDF backend to Skia/Skiko. This would eliminate the SVG intermediary — faster rendering, smaller files, and full gradient/effect support in vector mode. If this matters to you, upvote the PR! @@ -99,6 +105,6 @@ compose2pdf renders your Compose content through a **Skia SVGCanvas → Apache P ## Requirements -- **JDK 17** or later -- **Kotlin** 2.x -- **Compose Multiplatform** (Desktop) 1.9+ +- **JVM/Desktop**: JDK 17+, Kotlin 2.x, Compose Multiplatform 1.9+ +- **Android**: minSdk 24, Compose Multiplatform 1.9+ +- **iOS**: Compose Multiplatform 1.9+ diff --git a/docs/usage/vector-vs-raster.md b/docs/usage/vector-vs-raster.md index 851bc0e..42d661e 100644 --- a/docs/usage/vector-vs-raster.md +++ b/docs/usage/vector-vs-raster.md @@ -6,7 +6,10 @@ nav_order: 9 # Vector vs Raster Mode -compose2pdf supports two rendering modes. Choose the right one for your use case. +{: .note } +This page applies to **JVM only**. Android and iOS always produce vector output — there is no `RenderMode` parameter on those platforms. + +compose2pdf supports two rendering modes on JVM. Choose the right one for your use case. --- diff --git a/fidelity-test/build.gradle.kts b/fidelity-test/build.gradle.kts index fc71b69..a36cb82 100644 --- a/fidelity-test/build.gradle.kts +++ b/fidelity-test/build.gradle.kts @@ -6,7 +6,9 @@ plugins { dependencies { testImplementation(project(":compose2pdf")) + testImplementation(project(":test-fixtures")) testImplementation(compose.desktop.currentOs) + testImplementation(compose.material3) testImplementation(libs.kotlin.test) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.pdfbox) diff --git a/fidelity-test/src/test/kotlin/com/chrisjenx/compose2pdf/test/FidelityFixtures.kt b/fidelity-test/src/test/kotlin/com/chrisjenx/compose2pdf/test/FidelityFixtures.kt index 90b6fa4..156f8df 100644 --- a/fidelity-test/src/test/kotlin/com/chrisjenx/compose2pdf/test/FidelityFixtures.kt +++ b/fidelity-test/src/test/kotlin/com/chrisjenx/compose2pdf/test/FidelityFixtures.kt @@ -1,6 +1,5 @@ package com.chrisjenx.compose2pdf.test -import androidx.compose.foundation.Canvas import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -9,7 +8,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -25,25 +23,13 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.toComposeImageBitmap -import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.chrisjenx.compose2pdf.PdfLink import com.chrisjenx.compose2pdf.PdfPageConfig -import com.chrisjenx.compose2pdf.PdfRoundedCornerShape -import kotlin.math.cos -import kotlin.math.sin +import com.chrisjenx.compose2pdf.fixtures.* import org.jetbrains.skia.Color as SkColor import org.jetbrains.skia.Paint as SkPaint import org.jetbrains.skia.Rect as SkRect @@ -58,65 +44,19 @@ data class Fixture( val content: @Composable () -> Unit, ) -val fidelityFixtures = listOf( - // Basic - Fixture("simple-text", "basic", "Basic text rendering with multiple lines") { SimpleTextFixture() }, +val fidelityFixtures: List = sharedFixtures.map { sf -> + Fixture(sf.name, sf.category, sf.description, sf.vectorThreshold, sf.config, sf.content) +} + listOf( + // Skia-only fixtures (not in shared module) Fixture("with-image", "basic", "Embedded bitmap image with text") { WithImageFixture() }, - // Text - Fixture("styled-text", "text", "Bold, italic, colored, and sized text variants") { StyledTextFixture() }, - Fixture("text-wrapping", "text", "Long paragraph with soft wrap and ellipsis overflow", 0.15) { TextWrappingFixture() }, - Fixture("text-decoration", "text", "Underline, strikethrough, letter spacing, line height", 0.20) { TextDecorationFixture() }, - Fixture("text-alignment", "text", "Center, end, and justify text alignment", 0.20) { TextAlignmentFixture() }, - // Shapes - Fixture("rectangles", "shapes", "Filled and bordered rectangles in various sizes") { RectanglesFixture() }, - Fixture("rounded-corners", "shapes", "Rounded corners including circles and asymmetric radii") { RoundedCornersFixture() }, - Fixture("custom-drawing", "shapes", "Canvas drawing: circles, rectangles, lines, and arcs") { CustomDrawingFixture() }, - Fixture("complex-path", "shapes", "Canvas paths: star, cubic and quadratic beziers") { ComplexPathFixture() }, - Fixture("clip-shapes", "shapes", "Clipped content with circle, rounded, and nested clips") { ClipShapesFixture() }, - // Layout - Fixture("column-row-layout", "layout", "Column and Row layouts with weights and alignment") { ColumnRowLayoutFixture() }, - Fixture("borders-variety", "layout", "Various border widths, colors, shapes, and dividers") { BordersVarietyFixture() }, - Fixture("dense-grid", "layout", "8x8 grid of colored cells with text overlay", 0.40) { DenseGridFixture() }, - // Visual - Fixture("colors-backgrounds", "visual", "Solid color backgrounds with overlay text", 0.30) { ColorsBackgroundsFixture() }, - Fixture("opacity", "visual", "Semi-transparent overlapping colored boxes") { OpacityFixture() }, - // Composite - Fixture("invoice-like", "composite", "Invoice layout with headers, line items, and totals") { InvoiceLikeFixture() }, Fixture("mixed-content", "composite", "Dashboard card with image, text, shapes, and backgrounds", 0.20) { MixedContentFixture() }, - Fixture("pdf-links", "composite", "PdfLink annotations: plain, button, inline, large area") { PdfLinkFixture() }, - // Image variety Fixture("transparent-image", "basic", "Overlapping semi-transparent circles on white + colored backgrounds") { TransparentImageFixture() }, Fixture("gradient-image", "basic", "Horizontal gradient via thin vertical strips") { GradientImageFixture() }, Fixture("multiple-images", "basic", "6 images in a 2x3 grid") { MultipleImagesFixture() }, Fixture("scaled-image", "basic", "32x32 checkerboard at 32dp, 64dp, 128dp sizes") { ScaledImageFixture() }, - // Edge cases - Fixture("deep-nesting", "edge-case", "8 levels of clip + background + padding") { DeepNestingFixture() }, - Fixture("overlapping-elements", "edge-case", "Z-order with 3 overlapping semi-transparent boxes + text") { OverlappingElementsFixture() }, - Fixture("color-bands", "edge-case", "32 thin HSL color bands") { ColorBandsFixture() }, - Fixture("empty-page", "edge-case", "Nearly blank page with 1dp black marker") { EmptyPageFixture() }, - // Real-world documents - Fixture("detailed-invoice", "document", "Professional invoice with tax, discounts, and payment terms", 0.25) { DetailedInvoiceFixture() }, - Fixture("receipt", "document", "Point-of-sale receipt with itemized list and barcode placeholder") { ReceiptFixture() }, - Fixture("report-page", "document", "Business report page with KPI cards and data table", 0.25) { ReportPageFixture() }, - Fixture("form-layout", "document", "Structured form with labeled fields and checkboxes") { FormLayoutFixture() }, - Fixture("technical-diagram", "document", "Flowchart-style diagram with connected shapes", 0.20) { TechnicalDiagramFixture() }, - // Page sizes - Fixture("letter-page", "page-size", "US Letter size content", config = PdfPageConfig.Letter) { LetterPageFixture() }, - Fixture("a3-page", "page-size", "A3 size content", config = PdfPageConfig.A3) { A3PageFixture() }, ) -// -- Basic -- - -@Composable -private fun SimpleTextFixture() { - Column(Modifier.fillMaxSize().padding(24.dp)) { - Text("Hello, PDF!", fontSize = 24.sp) - Spacer(Modifier.height(8.dp)) - Text("This is a simple text fixture for fidelity testing.") - Spacer(Modifier.height(8.dp)) - Text("Line 3: numbers 0123456789 and symbols @#\$%") - } -} +// -- Skia-only fixtures (require org.jetbrains.skia for bitmap creation) -- @Composable private fun WithImageFixture() { @@ -152,464 +92,6 @@ private fun WithImageFixture() { } } -// -- Text -- - -@Composable -private fun StyledTextFixture() { - Column(Modifier.fillMaxSize().padding(24.dp)) { - Text("Bold Text", fontSize = 20.sp, fontWeight = FontWeight.Bold) - Spacer(Modifier.height(4.dp)) - Text("Italic Text", fontSize = 20.sp, fontStyle = FontStyle.Italic) - Spacer(Modifier.height(4.dp)) - Text("Large Text", fontSize = 36.sp, color = Color.Blue) - Spacer(Modifier.height(4.dp)) - Text("Small Red Text", fontSize = 10.sp, color = Color.Red) - Spacer(Modifier.height(4.dp)) - Text("Medium Green", fontSize = 16.sp, color = Color(0xFF006400)) - } -} - -@Composable -private fun TextWrappingFixture() { - Column(Modifier.fillMaxSize().padding(24.dp)) { - Text( - "This is a very long paragraph that should wrap across multiple lines to test how " + - "text wrapping is rendered in PDF output. The quick brown fox jumps over the lazy " + - "dog. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod " + - "tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam.", - fontSize = 14.sp, - softWrap = true, - ) - Spacer(Modifier.height(16.dp)) - Text( - "This text has a max of two lines and should show ellipsis if it overflows. " + - "Adding enough text here to make sure it overflows the available space in " + - "this column layout to trigger the ellipsis behavior correctly.", - fontSize = 14.sp, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } -} - -@Composable -private fun TextDecorationFixture() { - Column(Modifier.fillMaxSize().padding(24.dp)) { - Text("Underlined Text", fontSize = 18.sp, textDecoration = TextDecoration.Underline) - Spacer(Modifier.height(8.dp)) - Text("Strikethrough Text", fontSize = 18.sp, textDecoration = TextDecoration.LineThrough) - Spacer(Modifier.height(8.dp)) - Text( - "Combined Decorations", - fontSize = 18.sp, - textDecoration = TextDecoration.Underline + TextDecoration.LineThrough, - ) - Spacer(Modifier.height(8.dp)) - Text("Wide Letter Spacing", fontSize = 16.sp, letterSpacing = 4.sp) - Spacer(Modifier.height(8.dp)) - Text( - "Increased line height makes this text have more vertical space between lines " + - "when it wraps to multiple lines in the column.", - fontSize = 14.sp, - lineHeight = 28.sp, - ) - } -} - -@Composable -private fun TextAlignmentFixture() { - Column(Modifier.fillMaxSize().padding(24.dp)) { - Text( - "Left aligned (default)", - fontSize = 16.sp, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.height(12.dp)) - Text( - "Center aligned text", - fontSize = 16.sp, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.height(12.dp)) - Text( - "Right aligned text", - fontSize = 16.sp, - textAlign = TextAlign.End, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.height(16.dp)) - Box(Modifier.fillMaxWidth().background(Color(0xFFF5F5F5)).padding(8.dp)) { - Text( - "This is a paragraph of justified text. It should stretch to fill the full " + - "width of the container with even spacing between words on each line " + - "except the last line which remains left-aligned.", - fontSize = 14.sp, - textAlign = TextAlign.Justify, - modifier = Modifier.fillMaxWidth(), - ) - } - } -} - -// -- Shapes -- - -@Composable -private fun RectanglesFixture() { - Column(Modifier.fillMaxSize().padding(24.dp)) { - Box(Modifier.fillMaxWidth().height(60.dp).background(Color.Blue)) - Spacer(Modifier.height(8.dp)) - Box(Modifier.fillMaxWidth().height(40.dp).background(Color.Red)) - Spacer(Modifier.height(8.dp)) - Box( - Modifier.size(100.dp) - .border(2.dp, Color.Black) - .background(Color.Yellow), - ) - Spacer(Modifier.height(8.dp)) - Row { - Box(Modifier.size(50.dp).background(Color.Cyan)) - Spacer(Modifier.width(4.dp)) - Box(Modifier.size(50.dp).background(Color.Magenta)) - Spacer(Modifier.width(4.dp)) - Box(Modifier.size(50.dp).background(Color.Green)) - } - } -} - -@Composable -private fun RoundedCornersFixture() { - Column(Modifier.fillMaxSize().padding(24.dp)) { - Box( - Modifier.fillMaxWidth().height(60.dp) - .background(Color.Blue, RoundedCornerShape(12.dp)), - ) - Spacer(Modifier.height(8.dp)) - Box(Modifier.size(80.dp).background(Color.Red, CircleShape)) - Spacer(Modifier.height(8.dp)) - Box( - Modifier.fillMaxWidth().height(40.dp) - .background(Color.Green, RoundedCornerShape(topStart = 20.dp, bottomEnd = 20.dp)), - ) - Spacer(Modifier.height(8.dp)) - Box( - Modifier.size(100.dp) - .border(3.dp, Color.DarkGray, RoundedCornerShape(16.dp)) - .background(Color.LightGray, RoundedCornerShape(16.dp)), - ) - } -} - -@Composable -private fun CustomDrawingFixture() { - Canvas(Modifier.fillMaxSize().padding(24.dp)) { - drawCircle(color = Color.Red, radius = 40f, center = Offset(60f, 60f)) - drawCircle( - color = Color.Blue, radius = 40f, center = Offset(160f, 60f), - style = Stroke(width = 3f), - ) - drawRect(color = Color.Green, topLeft = Offset(10f, 120f), size = Size(100f, 60f)) - drawRect( - color = Color.DarkGray, topLeft = Offset(130f, 120f), size = Size(100f, 60f), - style = Stroke(width = 2f), - ) - drawLine(Color.Black, Offset(10f, 200f), Offset(250f, 200f), strokeWidth = 2f) - drawLine(Color.Red, Offset(10f, 210f), Offset(250f, 250f), strokeWidth = 1f) - drawArc( - color = Color.Blue, - startAngle = 0f, sweepAngle = 270f, - useCenter = true, - topLeft = Offset(10f, 270f), size = Size(80f, 80f), - ) - } -} - -@Composable -private fun ComplexPathFixture() { - Canvas(Modifier.fillMaxSize().padding(24.dp)) { - // 5-pointed star - val starPath = Path().apply { - val cx = 100f - val cy = 100f - val outerR = 80f - val innerR = 35f - for (i in 0 until 10) { - val r = if (i % 2 == 0) outerR else innerR - val angle = Math.PI / 2 + i * Math.PI / 5 - val x = cx + (r * cos(angle)).toFloat() - val y = cy - (r * sin(angle)).toFloat() - if (i == 0) moveTo(x, y) else lineTo(x, y) - } - close() - } - drawPath(starPath, Color(0xFFFF6600)) - drawPath(starPath, Color.Black, style = Stroke(width = 2f)) - - // Cubic bezier curve - val bezierPath = Path().apply { - moveTo(10f, 250f) - cubicTo(80f, 180f, 180f, 320f, 260f, 250f) - } - drawPath(bezierPath, Color.Blue, style = Stroke(width = 3f)) - - // Quadratic bezier wave - val wavePath = Path().apply { - moveTo(10f, 350f) - quadraticTo(70f, 300f, 130f, 350f) - quadraticTo(190f, 400f, 250f, 350f) - } - drawPath(wavePath, Color(0xFF006400), style = Stroke(width = 2f)) - } -} - -@Composable -private fun ClipShapesFixture() { - Column( - Modifier.fillMaxSize().padding(24.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Box( - Modifier.size(100.dp).clip(CircleShape).background(Color.Red), - contentAlignment = Alignment.Center, - ) { - Text("Circle", color = Color.White, fontSize = 12.sp) - } - Box( - Modifier.fillMaxWidth().height(60.dp) - .clip(RoundedCornerShape(16.dp)) - .background(Color.Blue), - contentAlignment = Alignment.Center, - ) { - Text("Rounded Clip", color = Color.White) - } - Box( - Modifier.size(120.dp, 80.dp) - .clip(PdfRoundedCornerShape(topStart = 24.dp, bottomEnd = 24.dp)) - .background(Color(0xFF6600FF)), - ) - // Nested clips - Box( - Modifier.size(100.dp) - .clip(RoundedCornerShape(20.dp)) - .background(Color.Yellow) - .padding(10.dp) - .clip(CircleShape) - .background(Color.Green), - ) - } -} - -// -- Layout -- - -@Composable -private fun ColumnRowLayoutFixture() { - Column( - Modifier.fillMaxSize().padding(24.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text("Header", fontSize = 20.sp, fontWeight = FontWeight.Bold) - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text("Left") - Text("Center") - Text("Right") - } - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - Box(Modifier.weight(1f).height(30.dp).background(Color.Red)) - Box(Modifier.weight(2f).height(30.dp).background(Color.Green)) - Box(Modifier.weight(1f).height(30.dp).background(Color.Blue)) - } - Column( - Modifier.fillMaxWidth().padding(16.dp).background(Color.LightGray), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text("Centered content") - Text("Inside padded box") - } - } -} - -@Composable -private fun BordersVarietyFixture() { - Column( - Modifier.fillMaxSize().padding(24.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Box(Modifier.fillMaxWidth().height(40.dp).border(1.dp, Color.Black)) - Box(Modifier.fillMaxWidth().height(40.dp).border(2.dp, Color.Red)) - Box( - Modifier.fillMaxWidth().height(40.dp) - .border(3.dp, Color.Blue, RoundedCornerShape(8.dp)), - ) - Box( - Modifier.fillMaxWidth().height(40.dp) - .border(4.dp, Color(0xFF006400), RoundedCornerShape(20.dp)), - ) - Divider(color = Color.Black, thickness = 1.dp) - Divider(color = Color.Red, thickness = 2.dp) - Divider(color = Color.Blue, thickness = 3.dp) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Box(Modifier.size(60.dp).border(1.dp, Color.Black).background(Color.LightGray)) - Box( - Modifier.size(60.dp) - .border(2.dp, Color.DarkGray, RoundedCornerShape(12.dp)) - .background(Color.Cyan, RoundedCornerShape(12.dp)), - ) - Box( - Modifier.size(60.dp) - .border(3.dp, Color.Magenta, CircleShape) - .background(Color.Yellow, CircleShape), - ) - } - } -} - -@Composable -private fun DenseGridFixture() { - val gridColors = listOf( - Color.Red, Color.Blue, Color.Green, Color.Yellow, - Color.Cyan, Color.Magenta, Color(0xFFFF6600), Color(0xFF6600FF), - ) - Column( - Modifier.fillMaxSize().padding(16.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - for (row in 0 until 8) { - Row( - Modifier.fillMaxWidth().height(48.dp), - horizontalArrangement = Arrangement.spacedBy(2.dp), - ) { - for (col in 0 until 8) { - val colorIndex = (row + col) % gridColors.size - Box( - Modifier.weight(1f).fillMaxHeight().background(gridColors[colorIndex]), - contentAlignment = Alignment.Center, - ) { - Text( - "${row * 8 + col}", - fontSize = 8.sp, - color = Color.White, - fontWeight = FontWeight.Bold, - fontFamily = FontFamily.Monospace, - ) - } - } - } - } - } -} - -// -- Visual -- - -@Composable -private fun ColorsBackgroundsFixture() { - Column(Modifier.fillMaxSize().padding(24.dp)) { - val colors = listOf( - Color.Red, Color.Green, Color.Blue, - Color.Yellow, Color.Cyan, Color.Magenta, - Color(0xFFFF6600), Color(0xFF6600FF), Color(0xFF00FF66), - ) - for (color in colors) { - Box( - Modifier.fillMaxWidth().height(24.dp).background(color), - ) { - Text( - "Color: ${color.hashCode()}", - fontSize = 10.sp, - color = Color.White, - modifier = Modifier.padding(start = 4.dp), - ) - } - Spacer(Modifier.height(2.dp)) - } - } -} - -@Composable -private fun OpacityFixture() { - Box(Modifier.fillMaxSize().padding(24.dp)) { - Box(Modifier.size(200.dp).background(Color.White)) - Box( - Modifier.padding(start = 20.dp, top = 20.dp) - .size(120.dp) - .background(Color.Red.copy(alpha = 0.5f)), - ) - Box( - Modifier.padding(start = 60.dp, top = 60.dp) - .size(120.dp) - .background(Color.Blue.copy(alpha = 0.5f)), - ) - Box( - Modifier.padding(start = 40.dp, top = 100.dp) - .size(120.dp) - .background(Color.Green.copy(alpha = 0.3f)), - ) - } -} - -// -- Composite -- - -@Composable -private fun InvoiceLikeFixture() { - Column(Modifier.fillMaxSize().padding(24.dp)) { - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Column { - Text("INVOICE", fontSize = 28.sp, fontWeight = FontWeight.Bold, color = Color(0xFF333333)) - Text("#INV-2024-001", fontSize = 14.sp, color = Color.Gray) - } - Column(horizontalAlignment = Alignment.End) { - Text("Acme Corp", fontSize = 16.sp, fontWeight = FontWeight.Bold) - Text("123 Business St", fontSize = 12.sp, color = Color.Gray) - Text("contact@acme.com", fontSize = 12.sp, color = Color.Blue) - } - } - - Spacer(Modifier.height(16.dp)) - Divider(color = Color.LightGray, thickness = 1.dp) - Spacer(Modifier.height(16.dp)) - - Row( - Modifier.fillMaxWidth().background(Color(0xFFF5F5F5)).padding(8.dp), - ) { - Text("Item", Modifier.weight(3f), fontWeight = FontWeight.Bold, fontSize = 12.sp) - Text("Qty", Modifier.weight(1f), fontWeight = FontWeight.Bold, fontSize = 12.sp) - Text("Price", Modifier.weight(1f), fontWeight = FontWeight.Bold, fontSize = 12.sp) - Text("Total", Modifier.weight(1f), fontWeight = FontWeight.Bold, fontSize = 12.sp) - } - - val items = listOf( - listOf("Widget Pro", "5", "\$10.00", "\$50.00"), - listOf("Gadget Basic", "2", "\$25.00", "\$50.00"), - listOf("Service Fee", "1", "\$100.00", "\$100.00"), - ) - for (item in items) { - Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp)) { - Text(item[0], Modifier.weight(3f), fontSize = 12.sp) - Text(item[1], Modifier.weight(1f), fontSize = 12.sp) - Text(item[2], Modifier.weight(1f), fontSize = 12.sp) - Text(item[3], Modifier.weight(1f), fontSize = 12.sp) - } - } - - Spacer(Modifier.height(8.dp)) - Divider(color = Color.LightGray, thickness = 1.dp) - Spacer(Modifier.height(8.dp)) - - Row( - Modifier.fillMaxWidth().padding(8.dp), - horizontalArrangement = Arrangement.End, - ) { - Text("Total: ", fontWeight = FontWeight.Bold, fontSize = 16.sp) - Text("\$200.00", fontWeight = FontWeight.Bold, fontSize = 16.sp, color = Color(0xFF006400)) - } - } -} - @Composable private fun MixedContentFixture() { val imageBitmap = remember { @@ -682,60 +164,6 @@ private fun MixedContentFixture() { } } -// -- Composite: PdfLink -- - -@Composable -internal fun PdfLinkFixture() { - Column( - Modifier.fillMaxSize().padding(24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - // Plain text link - PdfLink(href = "https://example.com") { - Text( - "Visit Example.com", - fontSize = 16.sp, - color = Color.Blue, - textDecoration = TextDecoration.Underline, - ) - } - // Button-style link - PdfLink(href = "https://github.com") { - Box( - Modifier - .background(Color(0xFF2196F3), RoundedCornerShape(8.dp)) - .padding(horizontal = 24.dp, vertical = 12.dp), - ) { - Text("GitHub", color = Color.White, fontWeight = FontWeight.Bold) - } - } - // Multiple inline links - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - PdfLink(href = "https://a.com") { - Text("Link A", color = Color.Blue, fontSize = 14.sp) - } - PdfLink(href = "https://b.com") { - Text("Link B", color = Color.Red, fontSize = 14.sp) - } - PdfLink(href = "https://c.com") { - Text("Link C", color = Color(0xFF006400), fontSize = 14.sp) - } - } - // Large clickable area - PdfLink(href = "https://large-area.com") { - Box( - Modifier.fillMaxWidth().height(80.dp) - .background(Color(0xFFF0F0F0), RoundedCornerShape(12.dp)), - contentAlignment = Alignment.Center, - ) { - Text("Large Clickable Area", fontSize = 18.sp, color = Color.DarkGray) - } - } - } -} - -// -- Image Variety -- - @Composable private fun TransparentImageFixture() { val imageBitmap = remember { @@ -879,595 +307,3 @@ private fun ScaledImageFixture() { } } } - -// -- Edge Cases -- - -@Composable -private fun DeepNestingFixture() { - val colors = listOf( - Color(0xFFE53935), Color(0xFFFF9800), Color(0xFFFDD835), - Color(0xFF43A047), Color(0xFF1E88E5), Color(0xFF5E35B1), - Color(0xFFE91E63), Color(0xFF00ACC1), - ) - var content: @Composable () -> Unit = { - Text("Deep!", fontSize = 12.sp, color = Color.White, fontWeight = FontWeight.Bold) - } - for (i in colors.indices.reversed()) { - val inner = content - val color = colors[i] - content = { - Box( - Modifier - .fillMaxSize() - .padding(8.dp) - .clip(RoundedCornerShape((4 + i * 2).dp)) - .background(color) - .padding(4.dp), - contentAlignment = Alignment.Center, - ) { - inner() - } - } - } - Box(Modifier.fillMaxSize().padding(24.dp)) { - content() - } -} - -@Composable -private fun OverlappingElementsFixture() { - Box(Modifier.fillMaxSize().padding(24.dp)) { - // White base - Box(Modifier.fillMaxSize().background(Color.White)) - // Three overlapping semi-transparent boxes - Box( - Modifier.padding(start = 20.dp, top = 40.dp) - .size(180.dp, 120.dp) - .background(Color.Red.copy(alpha = 0.6f), RoundedCornerShape(12.dp)), - ) - Box( - Modifier.padding(start = 80.dp, top = 80.dp) - .size(180.dp, 120.dp) - .background(Color.Blue.copy(alpha = 0.6f), RoundedCornerShape(12.dp)), - ) - Box( - Modifier.padding(start = 50.dp, top = 140.dp) - .size(180.dp, 120.dp) - .background(Color.Green.copy(alpha = 0.6f), RoundedCornerShape(12.dp)), - ) - // Text on top - Box( - Modifier.padding(start = 60.dp, top = 100.dp) - .background(Color.White.copy(alpha = 0.8f), RoundedCornerShape(4.dp)) - .padding(8.dp), - ) { - Text("Z-Order Test", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = Color.Black) - } - } -} - -@Composable -private fun ColorBandsFixture() { - Column(Modifier.fillMaxSize().padding(16.dp)) { - Text("32 Color Bands", fontSize = 16.sp, fontWeight = FontWeight.Bold) - Spacer(Modifier.height(8.dp)) - for (i in 0 until 32) { - val hue = i * (360f / 32f) - // HSL to RGB approximation - val c = hslToColor(hue, 0.7f, 0.5f) - Box( - Modifier.fillMaxWidth().height(12.dp).background(c), - ) - } - } -} - -@Composable -private fun EmptyPageFixture() { - Box(Modifier.fillMaxSize()) { - // Nearly blank -- just a tiny 1dp marker in the top-left corner - Box( - Modifier.padding(start = 1.dp, top = 1.dp) - .size(1.dp) - .background(Color.Black), - ) - } -} - -// -- Real-World Documents -- - -@Composable -private fun DetailedInvoiceFixture() { - Column(Modifier.fillMaxSize().padding(24.dp)) { - // Header - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Column { - Text("INVOICE", fontSize = 32.sp, fontWeight = FontWeight.Bold, color = Color(0xFF1A237E)) - Text("#INV-2024-0847", fontSize = 14.sp, color = Color.Gray) - Text("Date: March 15, 2024", fontSize = 12.sp, color = Color.Gray) - Text("Due: April 14, 2024", fontSize = 12.sp, color = Color.Red) - } - Column(horizontalAlignment = Alignment.End) { - Text("TechCorp Solutions", fontSize = 18.sp, fontWeight = FontWeight.Bold) - Text("742 Evergreen Terrace", fontSize = 11.sp, color = Color.Gray) - Text("Springfield, IL 62704", fontSize = 11.sp, color = Color.Gray) - Text("billing@techcorp.io", fontSize = 11.sp, color = Color(0xFF1565C0)) - } - } - Spacer(Modifier.height(12.dp)) - Divider(color = Color(0xFF1A237E), thickness = 2.dp) - Spacer(Modifier.height(8.dp)) - // Bill To - Text("Bill To:", fontSize = 12.sp, fontWeight = FontWeight.Bold, color = Color.Gray) - Text("Acme Industries Ltd.", fontSize = 14.sp, fontWeight = FontWeight.Bold) - Text("456 Oak Avenue, Suite 200", fontSize = 11.sp) - Spacer(Modifier.height(12.dp)) - // Table header - Row(Modifier.fillMaxWidth().background(Color(0xFF1A237E)).padding(8.dp)) { - Text("Description", Modifier.weight(3f), color = Color.White, fontSize = 11.sp, fontWeight = FontWeight.Bold) - Text("Qty", Modifier.weight(0.7f), color = Color.White, fontSize = 11.sp, fontWeight = FontWeight.Bold) - Text("Rate", Modifier.weight(1f), color = Color.White, fontSize = 11.sp, fontWeight = FontWeight.Bold) - Text("Amount", Modifier.weight(1f), color = Color.White, fontSize = 11.sp, fontWeight = FontWeight.Bold) - } - // Line items - val items = listOf( - listOf("Web Application Development", "40", "$150.00", "$6,000.00"), - listOf("UI/UX Design Services", "20", "$120.00", "$2,400.00"), - listOf("Database Architecture", "15", "$175.00", "$2,625.00"), - listOf("QA Testing & Code Review", "10", "$100.00", "$1,000.00"), - listOf("Project Management", "8", "$130.00", "$1,040.00"), - ) - for ((idx, item) in items.withIndex()) { - val bg = if (idx % 2 == 0) Color(0xFFF5F5F5) else Color.White - Row(Modifier.fillMaxWidth().background(bg).padding(horizontal = 8.dp, vertical = 4.dp)) { - Text(item[0], Modifier.weight(3f), fontSize = 11.sp) - Text(item[1], Modifier.weight(0.7f), fontSize = 11.sp) - Text(item[2], Modifier.weight(1f), fontSize = 11.sp) - Text(item[3], Modifier.weight(1f), fontSize = 11.sp) - } - } - Spacer(Modifier.height(8.dp)) - Divider() - // Totals - Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.End) { - Row(Modifier.width(200.dp), horizontalArrangement = Arrangement.SpaceBetween) { - Text("Subtotal:", fontSize = 12.sp) - Text("$13,065.00", fontSize = 12.sp) - } - Row(Modifier.width(200.dp), horizontalArrangement = Arrangement.SpaceBetween) { - Text("Discount (10%):", fontSize = 12.sp, color = Color(0xFF2E7D32)) - Text("-$1,306.50", fontSize = 12.sp, color = Color(0xFF2E7D32)) - } - Row(Modifier.width(200.dp), horizontalArrangement = Arrangement.SpaceBetween) { - Text("Tax (8.5%):", fontSize = 12.sp) - Text("$999.47", fontSize = 12.sp) - } - Divider(Modifier.width(200.dp)) - Row(Modifier.width(200.dp), horizontalArrangement = Arrangement.SpaceBetween) { - Text("Total Due:", fontSize = 14.sp, fontWeight = FontWeight.Bold) - Text("$12,757.97", fontSize = 14.sp, fontWeight = FontWeight.Bold, color = Color(0xFF1A237E)) - } - } - Spacer(Modifier.height(12.dp)) - // Payment terms - Box(Modifier.fillMaxWidth().background(Color(0xFFFFF3E0), RoundedCornerShape(8.dp)).padding(12.dp)) { - Text("Payment Terms: Net 30. Please make checks payable to TechCorp Solutions or wire to account ending 4892.", fontSize = 10.sp, color = Color(0xFFE65100)) - } - } -} - -@Composable -private fun ReceiptFixture() { - Column( - Modifier.fillMaxSize().padding(horizontal = 80.dp, vertical = 24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text("COFFEE HOUSE", fontSize = 22.sp, fontWeight = FontWeight.Bold) - Text("123 Main Street", fontSize = 11.sp, color = Color.Gray) - Text("Tel: (555) 123-4567", fontSize = 11.sp, color = Color.Gray) - Spacer(Modifier.height(8.dp)) - // Dashed line simulation - Text("- - - - - - - - - - - - - - - - - - - - - - - -", fontSize = 10.sp, color = Color.Gray) - Spacer(Modifier.height(4.dp)) - Text("ORDER #4821", fontSize = 14.sp, fontWeight = FontWeight.Bold) - Text("Mar 20, 2024 2:34 PM", fontSize = 11.sp, color = Color.Gray) - Spacer(Modifier.height(8.dp)) - val receiptItems = listOf( - Triple("Cappuccino (Large)", "x2", "$9.50"), - Triple("Blueberry Muffin", "x1", "$3.75"), - Triple("Avocado Toast", "x1", "$8.95"), - Triple("Fresh OJ", "x1", "$4.50"), - Triple("Chocolate Croissant", "x2", "$7.00"), - ) - for ((item, qty, price) in receiptItems) { - Row(Modifier.fillMaxWidth().padding(vertical = 2.dp)) { - Text(item, Modifier.weight(2f), fontSize = 12.sp) - Text(qty, Modifier.weight(0.5f), fontSize = 12.sp, textAlign = TextAlign.Center) - Text(price, Modifier.weight(0.8f), fontSize = 12.sp, textAlign = TextAlign.End) - } - } - Spacer(Modifier.height(4.dp)) - Text("- - - - - - - - - - - - - - - - - - - - - - - -", fontSize = 10.sp, color = Color.Gray) - Row(Modifier.fillMaxWidth()) { - Text("Subtotal", Modifier.weight(1f), fontSize = 12.sp) - Text("$33.70", fontSize = 12.sp, fontWeight = FontWeight.Bold) - } - Row(Modifier.fillMaxWidth()) { - Text("Tax (7%)", Modifier.weight(1f), fontSize = 12.sp, color = Color.Gray) - Text("$2.36", fontSize = 12.sp, color = Color.Gray) - } - Row(Modifier.fillMaxWidth()) { - Text("Tip", Modifier.weight(1f), fontSize = 12.sp, color = Color.Gray) - Text("$5.00", fontSize = 12.sp, color = Color.Gray) - } - Divider(thickness = 2.dp) - Row(Modifier.fillMaxWidth()) { - Text("TOTAL", Modifier.weight(1f), fontSize = 16.sp, fontWeight = FontWeight.Bold) - Text("$41.06", fontSize = 16.sp, fontWeight = FontWeight.Bold) - } - Spacer(Modifier.height(8.dp)) - Text("Paid with Visa **** 4242", fontSize = 11.sp, color = Color.Gray) - Spacer(Modifier.height(8.dp)) - // Barcode placeholder - Row(horizontalArrangement = Arrangement.Center) { - for (i in 0 until 30) { - val w = if (i % 3 == 0) 3.dp else 1.dp - Box(Modifier.width(w).height(40.dp).background(Color.Black)) - Spacer(Modifier.width(1.dp)) - } - } - Spacer(Modifier.height(4.dp)) - Text("Thank you for your visit!", fontSize = 12.sp, fontStyle = FontStyle.Italic) - } -} - -@Composable -private fun ReportPageFixture() { - Column(Modifier.fillMaxSize().padding(24.dp)) { - Text("Q1 2024 Performance Report", fontSize = 22.sp, fontWeight = FontWeight.Bold) - Text("Prepared: March 31, 2024", fontSize = 11.sp, color = Color.Gray) - Spacer(Modifier.height(12.dp)) - // KPI Cards - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - val kpis = listOf( - Triple("Revenue", "$2.4M", Color(0xFF1565C0)), - Triple("Users", "184K", Color(0xFF2E7D32)), - Triple("Churn", "2.1%", Color(0xFFE65100)), - Triple("NPS", "72", Color(0xFF6A1B9A)), - ) - for ((label, value, color) in kpis) { - Box( - Modifier.weight(1f) - .background(color.copy(alpha = 0.1f), RoundedCornerShape(8.dp)) - .border(1.dp, color.copy(alpha = 0.3f), RoundedCornerShape(8.dp)) - .padding(12.dp), - ) { - Column { - Text(label, fontSize = 10.sp, color = Color.Gray) - Text(value, fontSize = 20.sp, fontWeight = FontWeight.Bold, color = color) - } - } - } - } - Spacer(Modifier.height(12.dp)) - // Bar chart simulation - Text("Monthly Revenue (K)", fontSize = 12.sp, fontWeight = FontWeight.Bold) - Spacer(Modifier.height(4.dp)) - Row( - Modifier.fillMaxWidth().height(100.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.Bottom, - ) { - val bars = listOf(65f, 78f, 72f, 85f, 90f, 88f, 95f, 82f, 76f, 91f, 97f, 100f) - val months = listOf("J", "F", "M", "A", "M", "J", "J", "A", "S", "O", "N", "D") - for ((i, pct) in bars.withIndex()) { - Column( - Modifier.weight(1f), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Box( - Modifier.fillMaxWidth() - .height((pct * 0.8f).dp) - .background(Color(0xFF1565C0), RoundedCornerShape(topStart = 2.dp, topEnd = 2.dp)), - ) - Text(months[i], fontSize = 8.sp, color = Color.Gray) - } - } - } - Spacer(Modifier.height(12.dp)) - // Data table - Text("Top Customers", fontSize = 12.sp, fontWeight = FontWeight.Bold) - Spacer(Modifier.height(4.dp)) - Row(Modifier.fillMaxWidth().background(Color(0xFFE0E0E0)).padding(6.dp)) { - Text("Customer", Modifier.weight(2f), fontSize = 10.sp, fontWeight = FontWeight.Bold) - Text("Revenue", Modifier.weight(1f), fontSize = 10.sp, fontWeight = FontWeight.Bold) - Text("Growth", Modifier.weight(1f), fontSize = 10.sp, fontWeight = FontWeight.Bold) - } - val customers = listOf( - Triple("Acme Corp", "$342K", "+12%"), - Triple("Global Tech", "$298K", "+8%"), - Triple("Pinnacle Ltd", "$245K", "+22%"), - Triple("Atlas Industries", "$198K", "-3%"), - Triple("Nova Systems", "$176K", "+15%"), - ) - for ((name, rev, growth) in customers) { - Row(Modifier.fillMaxWidth().padding(horizontal = 6.dp, vertical = 3.dp)) { - Text(name, Modifier.weight(2f), fontSize = 10.sp) - Text(rev, Modifier.weight(1f), fontSize = 10.sp) - val growthColor = if (growth.startsWith("+")) Color(0xFF2E7D32) else Color.Red - Text(growth, Modifier.weight(1f), fontSize = 10.sp, color = growthColor) - } - Divider(color = Color(0xFFEEEEEE)) - } - } -} - -@Composable -private fun FormLayoutFixture() { - Column(Modifier.fillMaxSize().padding(24.dp)) { - Text("Patient Registration Form", fontSize = 20.sp, fontWeight = FontWeight.Bold) - Spacer(Modifier.height(4.dp)) - Text("Please fill in all required fields (*)", fontSize = 11.sp, color = Color.Gray) - Spacer(Modifier.height(12.dp)) - - // Form section - Text("Personal Information", fontSize = 14.sp, fontWeight = FontWeight.Bold, color = Color(0xFF1565C0)) - Divider(color = Color(0xFF1565C0)) - Spacer(Modifier.height(8.dp)) - - // Field rows - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - FormField("First Name *", "John", Modifier.weight(1f)) - FormField("Last Name *", "Doe", Modifier.weight(1f)) - } - Spacer(Modifier.height(8.dp)) - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - FormField("Date of Birth *", "1985-06-15", Modifier.weight(1f)) - FormField("Phone *", "(555) 867-5309", Modifier.weight(1f)) - } - Spacer(Modifier.height(8.dp)) - FormField("Email", "john.doe@email.com", Modifier.fillMaxWidth()) - Spacer(Modifier.height(8.dp)) - FormField("Address", "742 Evergreen Terrace, Springfield, IL 62704", Modifier.fillMaxWidth()) - - Spacer(Modifier.height(12.dp)) - Text("Insurance Information", fontSize = 14.sp, fontWeight = FontWeight.Bold, color = Color(0xFF1565C0)) - Divider(color = Color(0xFF1565C0)) - Spacer(Modifier.height(8.dp)) - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - FormField("Provider", "BlueCross Shield", Modifier.weight(1f)) - FormField("Policy #", "BC-4492-7781", Modifier.weight(1f)) - } - Spacer(Modifier.height(8.dp)) - FormField("Group #", "GRP-88421", Modifier.fillMaxWidth(0.5f)) - - Spacer(Modifier.height(12.dp)) - // Checkboxes - Text("Reason for Visit", fontSize = 14.sp, fontWeight = FontWeight.Bold, color = Color(0xFF1565C0)) - Divider(color = Color(0xFF1565C0)) - Spacer(Modifier.height(8.dp)) - val reasons = listOf("Annual Checkup" to true, "Follow-up" to false, "New Symptom" to true, "Referral" to false, "Lab Work" to false) - for ((reason, checked) in reasons) { - Row(Modifier.padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) { - Box( - Modifier.size(14.dp).border(1.dp, Color.DarkGray, RoundedCornerShape(2.dp)) - .background(if (checked) Color(0xFF1565C0) else Color.White, RoundedCornerShape(2.dp)), - contentAlignment = Alignment.Center, - ) { - if (checked) Text("\u2713", fontSize = 10.sp, color = Color.White) - } - Spacer(Modifier.width(6.dp)) - Text(reason, fontSize = 12.sp) - } - } - - Spacer(Modifier.height(16.dp)) - Box( - Modifier.fillMaxWidth(0.3f) - .background(Color(0xFF1565C0), RoundedCornerShape(4.dp)) - .padding(horizontal = 16.dp, vertical = 8.dp), - contentAlignment = Alignment.Center, - ) { - Text("Submit Form", fontSize = 12.sp, color = Color.White, fontWeight = FontWeight.Bold) - } - } -} - -@Composable -private fun FormField(label: String, value: String, modifier: Modifier) { - Column(modifier.then(Modifier.fillMaxWidth())) { - Text(label, fontSize = 10.sp, color = Color.Gray) - Box( - Modifier.fillMaxWidth() - .border(1.dp, Color(0xFFBDBDBD), RoundedCornerShape(4.dp)) - .padding(horizontal = 8.dp, vertical = 6.dp), - ) { - Text(value, fontSize = 12.sp) - } - } -} - -@Composable -private fun TechnicalDiagramFixture() { - Canvas(Modifier.fillMaxSize().padding(24.dp)) { - val boxW = 140f - val boxH = 50f - - // Draw boxes - fun drawBox(x: Float, y: Float, label: String, color: Long) { - drawRoundRect( - color = Color(color), - topLeft = Offset(x, y), - size = Size(boxW, boxH), - cornerRadius = androidx.compose.ui.geometry.CornerRadius(8f), - ) - drawRoundRect( - color = Color.Black, - topLeft = Offset(x, y), - size = Size(boxW, boxH), - cornerRadius = androidx.compose.ui.geometry.CornerRadius(8f), - style = Stroke(width = 2f), - ) - } - - // Draw arrows (simple lines with arrowheads) - fun drawArrow(x1: Float, y1: Float, x2: Float, y2: Float) { - drawLine(Color.Black, Offset(x1, y1), Offset(x2, y2), strokeWidth = 2f) - // Arrowhead - val dx = x2 - x1 - val dy = y2 - y1 - val len = kotlin.math.sqrt(dx * dx + dy * dy) - val ux = dx / len - val uy = dy / len - val ax = x2 - ux * 10f + uy * 5f - val ay = y2 - uy * 10f - ux * 5f - val bx = x2 - ux * 10f - uy * 5f - val by = y2 - uy * 10f + ux * 5f - val arrowPath = Path().apply { - moveTo(x2, y2) - lineTo(ax, ay) - lineTo(bx, by) - close() - } - drawPath(arrowPath, Color.Black) - } - - // Flowchart layout - val centerX = (size.width - boxW) / 2 - drawBox(centerX, 10f, "Start", 0xFFE3F2FD) - drawBox(centerX, 100f, "Process A", 0xFFFFF3E0) - drawBox(centerX - 100f, 200f, "Decision", 0xFFE8F5E9) - drawBox(centerX + 100f, 200f, "Process B", 0xFFFCE4EC) - drawBox(centerX, 300f, "Merge", 0xFFF3E5F5) - drawBox(centerX, 390f, "End", 0xFFE3F2FD) - - // Arrows - drawArrow(centerX + boxW / 2, 60f, centerX + boxW / 2, 100f) - drawArrow(centerX + boxW / 2, 150f, centerX - 100f + boxW / 2, 200f) - drawArrow(centerX + boxW / 2, 150f, centerX + 100f + boxW / 2, 200f) - drawArrow(centerX - 100f + boxW / 2, 250f, centerX + boxW / 2, 300f) - drawArrow(centerX + 100f + boxW / 2, 250f, centerX + boxW / 2, 300f) - drawArrow(centerX + boxW / 2, 350f, centerX + boxW / 2, 390f) - - // Diamond for decision - val dcx = centerX - 100f + boxW / 2 - val dcy = 225f - val diamond = Path().apply { - moveTo(dcx, dcy - 20f) - lineTo(dcx + 30f, dcy) - lineTo(dcx, dcy + 20f) - lineTo(dcx - 30f, dcy) - close() - } - drawPath(diamond, Color(0xFF4CAF50), style = Stroke(width = 2f)) - - // Yes/No labels - drawCircle(Color.White, radius = 12f, center = Offset(centerX - 40f, 175f)) - drawCircle(Color.White, radius = 12f, center = Offset(centerX + boxW + 40f, 175f)) - } -} - -// -- Page Size Fixtures -- - -@Composable -private fun LetterPageFixture() { - Column(Modifier.fillMaxSize().padding(24.dp)) { - Text("US Letter Format", fontSize = 24.sp, fontWeight = FontWeight.Bold) - Spacer(Modifier.height(8.dp)) - Text("8.5 x 11 inches (215.9 x 279.4 mm)", fontSize = 14.sp, color = Color.Gray) - Spacer(Modifier.height(16.dp)) - Box( - Modifier.fillMaxWidth().height(60.dp) - .background(Color(0xFF1565C0), RoundedCornerShape(8.dp)), - contentAlignment = Alignment.Center, - ) { - Text("Header Section", color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Bold) - } - Spacer(Modifier.height(12.dp)) - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Box( - Modifier.weight(1f).height(120.dp) - .background(Color(0xFFE3F2FD), RoundedCornerShape(8.dp)) - .padding(12.dp), - ) { Text("Column A content with text wrapping to test layout on Letter paper.", fontSize = 12.sp) } - Box( - Modifier.weight(1f).height(120.dp) - .background(Color(0xFFFFF3E0), RoundedCornerShape(8.dp)) - .padding(12.dp), - ) { Text("Column B content verifying width calculations match Letter dimensions.", fontSize = 12.sp) } - } - Spacer(Modifier.height(12.dp)) - for (i in 1..5) { - Text("Paragraph $i: Standard body text line for Letter format fidelity testing.", fontSize = 12.sp) - Spacer(Modifier.height(4.dp)) - } - } -} - -@Composable -private fun A3PageFixture() { - Column(Modifier.fillMaxSize().padding(32.dp)) { - Text("A3 Format Document", fontSize = 32.sp, fontWeight = FontWeight.Bold) - Text("297 x 420 mm -- Larger format for posters and presentations", fontSize = 16.sp, color = Color.Gray) - Spacer(Modifier.height(16.dp)) - // 3-column layout (A3 is wide enough) - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - for (col in listOf("Overview", "Details", "Summary")) { - Box( - Modifier.weight(1f).height(200.dp) - .background(Color(0xFFF5F5F5), RoundedCornerShape(12.dp)) - .border(1.dp, Color(0xFFBDBDBD), RoundedCornerShape(12.dp)) - .padding(16.dp), - ) { - Column { - Text(col, fontSize = 18.sp, fontWeight = FontWeight.Bold) - Spacer(Modifier.height(8.dp)) - Text( - "A3 provides extra width for multi-column layouts. This section " + - "verifies correct rendering at the larger page dimensions.", - fontSize = 13.sp, - ) - } - } - } - } - Spacer(Modifier.height(16.dp)) - // Wide data table (6 columns) - Row(Modifier.fillMaxWidth().background(Color(0xFF424242)).padding(8.dp)) { - for (header in listOf("ID", "Product", "Category", "Price", "Stock", "Status")) { - Text(header, Modifier.weight(1f), color = Color.White, fontSize = 12.sp, fontWeight = FontWeight.Bold) - } - } - val data = listOf( - listOf("001", "Widget Pro", "Hardware", "$49.99", "342", "Active"), - listOf("002", "Gadget X", "Electronics", "$129.00", "87", "Active"), - listOf("003", "Service Plan", "Subscription", "$9.99/mo", "1204", "Active"), - listOf("004", "Cable Kit", "Accessories", "$24.50", "0", "Out of Stock"), - ) - for ((idx, row) in data.withIndex()) { - val bg = if (idx % 2 == 0) Color(0xFFF5F5F5) else Color.White - Row(Modifier.fillMaxWidth().background(bg).padding(horizontal = 8.dp, vertical = 4.dp)) { - for (cell in row) { - Text(cell, Modifier.weight(1f), fontSize = 11.sp) - } - } - } - } -} - -// -- Helpers -- - -private fun hslToColor(h: Float, s: Float, l: Float): Color { - val c = (1f - kotlin.math.abs(2f * l - 1f)) * s - val x = c * (1f - kotlin.math.abs((h / 60f) % 2f - 1f)) - val m = l - c / 2f - val (r, g, b) = when { - h < 60f -> Triple(c, x, 0f) - h < 120f -> Triple(x, c, 0f) - h < 180f -> Triple(0f, c, x) - h < 240f -> Triple(0f, x, c) - h < 300f -> Triple(x, 0f, c) - else -> Triple(c, 0f, x) - } - return Color(r + m, g + m, b + m) -} diff --git a/fidelity-test/src/test/kotlin/com/chrisjenx/compose2pdf/test/FidelityReport.kt b/fidelity-test/src/test/kotlin/com/chrisjenx/compose2pdf/test/FidelityReport.kt index a17f048..0a55026 100644 --- a/fidelity-test/src/test/kotlin/com/chrisjenx/compose2pdf/test/FidelityReport.kt +++ b/fidelity-test/src/test/kotlin/com/chrisjenx/compose2pdf/test/FidelityReport.kt @@ -29,7 +29,28 @@ data class FidelityResult( // PDF file paths (relative to report dir) val vectorPdfPath: String = "", val rasterPdfPath: String = "", + // Android cross-platform comparison (optional) + val androidPath: String = "", + val androidDiffPath: String = "", + val androidPdfPath: String = "", + val androidRmse: Double = -1.0, + val androidSsim: Double = -1.0, + val androidExactMatch: Double = -1.0, + val androidMaxError: Double = -1.0, + val androidStatus: Status = Status.SKIPPED, + // iOS cross-platform comparison (optional) + val iosPath: String = "", + val iosDiffPath: String = "", + val iosPdfPath: String = "", + val iosRmse: Double = -1.0, + val iosSsim: Double = -1.0, + val iosExactMatch: Double = -1.0, + val iosMaxError: Double = -1.0, + val iosStatus: Status = Status.SKIPPED, ) { + val hasAndroid: Boolean get() = androidStatus != Status.SKIPPED + val hasIos: Boolean get() = iosStatus != Status.SKIPPED + val rowStatus: Status get() { val statuses = listOf(vectorStatus, rasterStatus) @@ -340,6 +361,54 @@ h1 { margin: 0 0 4px 0; font-size: 24px; } appendLine("") appendLine("") + // Android section (if available) + if (result.hasAndroid) { + val aStatusClass = "${result.androidStatus.cssClass}-text" + appendLine("
") + appendLine("
Android
") + appendLine("
") + appendLine("
") + appendLine("
Rendered
") + if (result.androidPdfPath.isNotEmpty()) { + appendLine("Open PDF") + } + appendLine("
") + appendLine("
Diff
") + appendLine("
") + appendLine("
") + appendLine("
RMSE${"%.4f".format(result.androidRmse)}
") + appendLine("
SSIM${"%.4f".format(result.androidSsim)}
") + appendLine("
Match${"%.2f".format(result.androidExactMatch * 100)}%
") + appendLine("
MaxErr${"%.4f".format(result.androidMaxError)}
") + appendLine("
Status${result.androidStatus.label}
") + appendLine("
") + appendLine("
") + } + + // iOS section (if available) + if (result.hasIos) { + val iStatusClass = "${result.iosStatus.cssClass}-text" + appendLine("
") + appendLine("
iOS
") + appendLine("
") + appendLine("
") + appendLine("
Rendered
") + if (result.iosPdfPath.isNotEmpty()) { + appendLine("Open PDF") + } + appendLine("
") + appendLine("
Diff
") + appendLine("
") + appendLine("
") + appendLine("
RMSE${"%.4f".format(result.iosRmse)}
") + appendLine("
SSIM${"%.4f".format(result.iosSsim)}
") + appendLine("
Match${"%.2f".format(result.iosExactMatch * 100)}%
") + appendLine("
MaxErr${"%.4f".format(result.iosMaxError)}
") + appendLine("
Status${result.iosStatus.label}
") + appendLine("
") + appendLine("
") + } + appendLine("") // modes-col appendLine("") // card-body appendLine("") // fixture-card diff --git a/fidelity-test/src/test/kotlin/com/chrisjenx/compose2pdf/test/FidelityTest.kt b/fidelity-test/src/test/kotlin/com/chrisjenx/compose2pdf/test/FidelityTest.kt index 89e57fa..8b8636f 100644 --- a/fidelity-test/src/test/kotlin/com/chrisjenx/compose2pdf/test/FidelityTest.kt +++ b/fidelity-test/src/test/kotlin/com/chrisjenx/compose2pdf/test/FidelityTest.kt @@ -24,6 +24,11 @@ class FidelityTest { private val reportDir = File("build/reports/fidelity") private val imagesDir = File(reportDir, "images") + // Android PDFs from GMD test output (run :compose2pdf:pixel2api30atdDebugAndroidTest first) + private val androidPdfDir = findAndroidPdfDir() + // iOS PDFs from simulator test output (run :compose2pdf:iosSimulatorArm64Test first) + private val iosPdfDir = findIosPdfDir() + @Test fun `fidelity comparison of all fixtures`() { imagesDir.mkdirs() @@ -129,6 +134,48 @@ class FidelityTest { val rasterDiff = ImageMetrics.generateStructuralDiffImage(composeImage, rasterImage) saveImage(rasterDiff, imagesDir, "${fixture.name}-raster-diff.png") + // 7. Android cross-platform comparison (optional — requires prior GMD run) + val androidPdf = androidPdfDir?.let { File(it, "${fixture.name}-android.pdf") } + val androidResult = if (androidPdf != null && androidPdf.exists()) { + try { + val androidPdfBytes = androidPdf.readBytes() + androidPdf.copyTo(File(imagesDir, "${fixture.name}-android.pdf"), overwrite = true) + val androidImage = rasterizePdf(androidPdfBytes, renderDpi) + saveImage(androidImage, imagesDir, "${fixture.name}-android.png") + val aRmse = ImageMetrics.computeRmse(composeImage, androidImage) + val aSsim = ImageMetrics.computeSsim(composeImage, androidImage) + val aExactMatch = ImageMetrics.computeExactMatchPercent(composeImage, androidImage) + val aMaxError = ImageMetrics.computeMaxPixelError(composeImage, androidImage) + val aDiff = ImageMetrics.generateStructuralDiffImage(composeImage, androidImage) + saveImage(aDiff, imagesDir, "${fixture.name}-android-diff.png") + PlatformMetrics(aRmse, aSsim, aExactMatch, aMaxError) + } catch (e: Exception) { + println(" Warning: failed to process Android PDF for ${fixture.name}: ${e.message}") + null + } + } else null + + // 8. iOS cross-platform comparison (optional — requires prior simulator test run) + val iosPdf = iosPdfDir?.let { File(it, "${fixture.name}-ios.pdf") } + val iosResult = if (iosPdf != null && iosPdf.exists()) { + try { + val iosPdfBytes = iosPdf.readBytes() + iosPdf.copyTo(File(imagesDir, "${fixture.name}-ios.pdf"), overwrite = true) + val iosImage = rasterizePdf(iosPdfBytes, renderDpi) + saveImage(iosImage, imagesDir, "${fixture.name}-ios.png") + val iRmse = ImageMetrics.computeRmse(composeImage, iosImage) + val iSsim = ImageMetrics.computeSsim(composeImage, iosImage) + val iExactMatch = ImageMetrics.computeExactMatchPercent(composeImage, iosImage) + val iMaxError = ImageMetrics.computeMaxPixelError(composeImage, iosImage) + val iDiff = ImageMetrics.generateStructuralDiffImage(composeImage, iosImage) + saveImage(iDiff, imagesDir, "${fixture.name}-ios-diff.png") + PlatformMetrics(iRmse, iSsim, iExactMatch, iMaxError) + } catch (e: Exception) { + println(" Warning: failed to process iOS PDF for ${fixture.name}: ${e.message}") + null + } + } else null + return FidelityResult( name = fixture.name, category = fixture.category, @@ -150,6 +197,73 @@ class FidelityTest { rasterDiffPath = "images/${fixture.name}-raster-diff.png", vectorPdfPath = "images/${fixture.name}-vector.pdf", rasterPdfPath = "images/${fixture.name}-raster.pdf", + androidPath = if (androidResult != null) "images/${fixture.name}-android.png" else "", + androidDiffPath = if (androidResult != null) "images/${fixture.name}-android-diff.png" else "", + androidPdfPath = if (androidResult != null) "images/${fixture.name}-android.pdf" else "", + androidRmse = androidResult?.rmse ?: -1.0, + androidSsim = androidResult?.ssim ?: -1.0, + androidExactMatch = androidResult?.exactMatch ?: -1.0, + androidMaxError = androidResult?.maxError ?: -1.0, + androidStatus = if (androidResult != null) vectorStatus(androidResult.rmse, fixture.vectorThreshold) else Status.SKIPPED, + iosPath = if (iosResult != null) "images/${fixture.name}-ios.png" else "", + iosDiffPath = if (iosResult != null) "images/${fixture.name}-ios-diff.png" else "", + iosPdfPath = if (iosResult != null) "images/${fixture.name}-ios.pdf" else "", + iosRmse = iosResult?.rmse ?: -1.0, + iosSsim = iosResult?.ssim ?: -1.0, + iosExactMatch = iosResult?.exactMatch ?: -1.0, + iosMaxError = iosResult?.maxError ?: -1.0, + iosStatus = if (iosResult != null) vectorStatus(iosResult.rmse, fixture.vectorThreshold) else Status.SKIPPED, ) } + + private data class PlatformMetrics( + val rmse: Double, + val ssim: Double, + val exactMatch: Double, + val maxError: Double, + ) + + companion object { + /** Searches for Android PDF output from GMD tests. */ + private fun findAndroidPdfDir(): File? { + // Standard GMD output path (relative to fidelity-test working dir) + val candidates = listOf( + File("../compose2pdf/build/outputs/managed_device_android_test_additional_output/debug/pixel2api30atd"), + File("../compose2pdf/build/outputs/managed_device_android_test_additional_output/debug"), + ) + for (candidate in candidates) { + if (candidate.isDirectory && candidate.listFiles()?.any { it.name.endsWith("-android.pdf") } == true) { + println("Found Android PDFs at: ${candidate.absolutePath}") + return candidate + } + } + // Search recursively under the Android output dir + val baseDir = File("../compose2pdf/build/outputs/managed_device_android_test_additional_output") + if (baseDir.isDirectory) { + baseDir.walk().maxDepth(3).forEach { dir -> + if (dir.isDirectory && dir.listFiles()?.any { it.name.endsWith("-android.pdf") } == true) { + println("Found Android PDFs at: ${dir.absolutePath}") + return dir + } + } + } + println("No Android PDFs found — run :compose2pdf:pixel2api30atdDebugAndroidTest first for cross-platform comparison") + return null + } + + /** Searches for iOS PDF output from simulator tests. */ + private fun findIosPdfDir(): File? { + val candidates = listOf( + File("/tmp/compose2pdf-ios-test-output"), + ) + for (candidate in candidates) { + if (candidate.isDirectory && candidate.listFiles()?.any { it.name.endsWith("-ios.pdf") } == true) { + println("Found iOS PDFs at: ${candidate.absolutePath}") + return candidate + } + } + println("No iOS PDFs found — run :compose2pdf:iosSimulatorArm64Test first for cross-platform comparison") + return null + } + } } diff --git a/fidelity-test/src/test/kotlin/com/chrisjenx/compose2pdf/test/LinkAnnotationTest.kt b/fidelity-test/src/test/kotlin/com/chrisjenx/compose2pdf/test/LinkAnnotationTest.kt index f8025cc..1351161 100644 --- a/fidelity-test/src/test/kotlin/com/chrisjenx/compose2pdf/test/LinkAnnotationTest.kt +++ b/fidelity-test/src/test/kotlin/com/chrisjenx/compose2pdf/test/LinkAnnotationTest.kt @@ -3,6 +3,7 @@ package com.chrisjenx.compose2pdf.test import androidx.compose.ui.unit.Density import com.chrisjenx.compose2pdf.PdfPageConfig import com.chrisjenx.compose2pdf.RenderMode +import com.chrisjenx.compose2pdf.fixtures.PdfLinkFixture import com.chrisjenx.compose2pdf.renderToPdf import org.apache.pdfbox.Loader import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationLink diff --git a/gradle.properties b/gradle.properties index c98246c..6ee792d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,6 @@ org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m org.gradle.parallel=true org.gradle.caching=true +android.useAndroidX=true version=1.0.1-SNAPSHOT diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3a9f70f..38077e8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,7 @@ [versions] kotlin = "2.3.20" compose-multiplatform = "1.10.3" +agp = "8.9.3" pdfbox = "3.0.7" kotlinx-coroutines = "1.10.2" maven-publish = "0.34.0" @@ -12,6 +13,8 @@ kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-t [plugins] kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +android-library = { id = "com.android.library", version.ref = "agp" } compose = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } maven-publish = { id = "com.vanniktech.maven.publish", version.ref = "maven-publish" } diff --git a/settings.gradle.kts b/settings.gradle.kts index f16bd96..7d105f8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -17,5 +17,6 @@ dependencyResolutionManagement { rootProject.name = "compose2pdf" include(":compose2pdf") +include(":test-fixtures") include(":fidelity-test") include(":examples") diff --git a/test-fixtures/build.gradle.kts b/test-fixtures/build.gradle.kts new file mode 100644 index 0000000..5cf4735 --- /dev/null +++ b/test-fixtures/build.gradle.kts @@ -0,0 +1,42 @@ +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.android.library) + alias(libs.plugins.compose) + alias(libs.plugins.compose.compiler) +} + +kotlin { + jvmToolchain(17) + + jvm() + + androidTarget() + + listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64(), + ) + + sourceSets { + commonMain.dependencies { + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.ui) + implementation(compose.material3) + implementation(project(":compose2pdf")) + } + } +} + +android { + namespace = "com.chrisjenx.compose2pdf.fixtures" + compileSdk = 35 + defaultConfig { + minSdk = 24 + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} diff --git a/test-fixtures/src/commonMain/kotlin/com/chrisjenx/compose2pdf/fixtures/SharedFixtures.kt b/test-fixtures/src/commonMain/kotlin/com/chrisjenx/compose2pdf/fixtures/SharedFixtures.kt new file mode 100644 index 0000000..966bd2a --- /dev/null +++ b/test-fixtures/src/commonMain/kotlin/com/chrisjenx/compose2pdf/fixtures/SharedFixtures.kt @@ -0,0 +1,1207 @@ +package com.chrisjenx.compose2pdf.fixtures + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.chrisjenx.compose2pdf.PdfLink +import com.chrisjenx.compose2pdf.PdfPageConfig +import com.chrisjenx.compose2pdf.PdfRoundedCornerShape +import kotlin.math.cos +import kotlin.math.sin + +data class SharedFixture( + val name: String, + val category: String = "basic", + val description: String = "", + val vectorThreshold: Double = 0.15, + val config: PdfPageConfig = PdfPageConfig.A4, + val content: @Composable () -> Unit, +) + +val sharedFixtures = listOf( + // Basic + SharedFixture("simple-text", "basic", "Basic text rendering with multiple lines") { SimpleTextFixture() }, + // Text + SharedFixture("styled-text", "text", "Bold, italic, colored, and sized text variants") { StyledTextFixture() }, + SharedFixture("text-wrapping", "text", "Long paragraph with soft wrap and ellipsis overflow", 0.15) { TextWrappingFixture() }, + SharedFixture("text-decoration", "text", "Underline, strikethrough, letter spacing, line height", 0.20) { TextDecorationFixture() }, + SharedFixture("text-alignment", "text", "Center, end, and justify text alignment", 0.20) { TextAlignmentFixture() }, + // Shapes + SharedFixture("rectangles", "shapes", "Filled and bordered rectangles in various sizes") { RectanglesFixture() }, + SharedFixture("rounded-corners", "shapes", "Rounded corners including circles and asymmetric radii") { RoundedCornersFixture() }, + SharedFixture("custom-drawing", "shapes", "Canvas drawing: circles, rectangles, lines, and arcs") { CustomDrawingFixture() }, + SharedFixture("complex-path", "shapes", "Canvas paths: star, cubic and quadratic beziers") { ComplexPathFixture() }, + SharedFixture("clip-shapes", "shapes", "Clipped content with circle, rounded, and nested clips") { ClipShapesFixture() }, + // Layout + SharedFixture("column-row-layout", "layout", "Column and Row layouts with weights and alignment") { ColumnRowLayoutFixture() }, + SharedFixture("borders-variety", "layout", "Various border widths, colors, shapes, and dividers") { BordersVarietyFixture() }, + SharedFixture("dense-grid", "layout", "8x8 grid of colored cells with text overlay", 0.40) { DenseGridFixture() }, + // Visual + SharedFixture("colors-backgrounds", "visual", "Solid color backgrounds with overlay text", 0.30) { ColorsBackgroundsFixture() }, + SharedFixture("opacity", "visual", "Semi-transparent overlapping colored boxes") { OpacityFixture() }, + // Composite + SharedFixture("invoice-like", "composite", "Invoice layout with headers, line items, and totals") { InvoiceLikeFixture() }, + SharedFixture("pdf-links", "composite", "PdfLink annotations: plain, button, inline, large area") { PdfLinkFixture() }, + // Edge cases + SharedFixture("deep-nesting", "edge-case", "8 levels of clip + background + padding") { DeepNestingFixture() }, + SharedFixture("overlapping-elements", "edge-case", "Z-order with 3 overlapping semi-transparent boxes + text") { OverlappingElementsFixture() }, + SharedFixture("color-bands", "edge-case", "32 thin HSL color bands") { ColorBandsFixture() }, + SharedFixture("empty-page", "edge-case", "Nearly blank page with 1dp black marker") { EmptyPageFixture() }, + // Real-world documents + SharedFixture("detailed-invoice", "document", "Professional invoice with tax, discounts, and payment terms", 0.25) { DetailedInvoiceFixture() }, + SharedFixture("receipt", "document", "Point-of-sale receipt with itemized list and barcode placeholder") { ReceiptFixture() }, + SharedFixture("report-page", "document", "Business report page with KPI cards and data table", 0.25) { ReportPageFixture() }, + SharedFixture("form-layout", "document", "Structured form with labeled fields and checkboxes") { FormLayoutFixture() }, + SharedFixture("technical-diagram", "document", "Flowchart-style diagram with connected shapes", 0.20) { TechnicalDiagramFixture() }, + // Page sizes + SharedFixture("letter-page", "page-size", "US Letter size content", config = PdfPageConfig.Letter) { LetterPageFixture() }, + SharedFixture("a3-page", "page-size", "A3 size content", config = PdfPageConfig.A3) { A3PageFixture() }, +) + +// -- Basic -- + +@Composable +fun SimpleTextFixture() { + Column(Modifier.fillMaxSize().padding(24.dp)) { + Text("Hello, PDF!", fontSize = 24.sp) + Spacer(Modifier.height(8.dp)) + Text("This is a simple text fixture for fidelity testing.") + Spacer(Modifier.height(8.dp)) + Text("Line 3: numbers 0123456789 and symbols @#\$%") + } +} + +// -- Text -- + +@Composable +fun StyledTextFixture() { + Column(Modifier.fillMaxSize().padding(24.dp)) { + Text("Bold Text", fontSize = 20.sp, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(4.dp)) + Text("Italic Text", fontSize = 20.sp, fontStyle = FontStyle.Italic) + Spacer(Modifier.height(4.dp)) + Text("Large Text", fontSize = 36.sp, color = Color.Blue) + Spacer(Modifier.height(4.dp)) + Text("Small Red Text", fontSize = 10.sp, color = Color.Red) + Spacer(Modifier.height(4.dp)) + Text("Medium Green", fontSize = 16.sp, color = Color(0xFF006400)) + } +} + +@Composable +fun TextWrappingFixture() { + Column(Modifier.fillMaxSize().padding(24.dp)) { + Text( + "This is a very long paragraph that should wrap across multiple lines to test how " + + "text wrapping is rendered in PDF output. The quick brown fox jumps over the lazy " + + "dog. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod " + + "tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam.", + fontSize = 14.sp, + softWrap = true, + ) + Spacer(Modifier.height(16.dp)) + Text( + "This text has a max of two lines and should show ellipsis if it overflows. " + + "Adding enough text here to make sure it overflows the available space in " + + "this column layout to trigger the ellipsis behavior correctly.", + fontSize = 14.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +fun TextDecorationFixture() { + Column(Modifier.fillMaxSize().padding(24.dp)) { + Text("Underlined Text", fontSize = 18.sp, textDecoration = TextDecoration.Underline) + Spacer(Modifier.height(8.dp)) + Text("Strikethrough Text", fontSize = 18.sp, textDecoration = TextDecoration.LineThrough) + Spacer(Modifier.height(8.dp)) + Text( + "Combined Decorations", + fontSize = 18.sp, + textDecoration = TextDecoration.Underline + TextDecoration.LineThrough, + ) + Spacer(Modifier.height(8.dp)) + Text("Wide Letter Spacing", fontSize = 16.sp, letterSpacing = 4.sp) + Spacer(Modifier.height(8.dp)) + Text( + "Increased line height makes this text have more vertical space between lines " + + "when it wraps to multiple lines in the column.", + fontSize = 14.sp, + lineHeight = 28.sp, + ) + } +} + +@Composable +fun TextAlignmentFixture() { + Column(Modifier.fillMaxSize().padding(24.dp)) { + Text( + "Left aligned (default)", + fontSize = 16.sp, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(12.dp)) + Text( + "Center aligned text", + fontSize = 16.sp, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(12.dp)) + Text( + "Right aligned text", + fontSize = 16.sp, + textAlign = TextAlign.End, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(16.dp)) + Box(Modifier.fillMaxWidth().background(Color(0xFFF5F5F5)).padding(8.dp)) { + Text( + "This is a paragraph of justified text. It should stretch to fill the full " + + "width of the container with even spacing between words on each line " + + "except the last line which remains left-aligned.", + fontSize = 14.sp, + textAlign = TextAlign.Justify, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +// -- Shapes -- + +@Composable +fun RectanglesFixture() { + Column(Modifier.fillMaxSize().padding(24.dp)) { + Box(Modifier.fillMaxWidth().height(60.dp).background(Color.Blue)) + Spacer(Modifier.height(8.dp)) + Box(Modifier.fillMaxWidth().height(40.dp).background(Color.Red)) + Spacer(Modifier.height(8.dp)) + Box( + Modifier.size(100.dp) + .border(2.dp, Color.Black) + .background(Color.Yellow), + ) + Spacer(Modifier.height(8.dp)) + Row { + Box(Modifier.size(50.dp).background(Color.Cyan)) + Spacer(Modifier.width(4.dp)) + Box(Modifier.size(50.dp).background(Color.Magenta)) + Spacer(Modifier.width(4.dp)) + Box(Modifier.size(50.dp).background(Color.Green)) + } + } +} + +@Composable +fun RoundedCornersFixture() { + Column(Modifier.fillMaxSize().padding(24.dp)) { + Box( + Modifier.fillMaxWidth().height(60.dp) + .background(Color.Blue, RoundedCornerShape(12.dp)), + ) + Spacer(Modifier.height(8.dp)) + Box(Modifier.size(80.dp).background(Color.Red, CircleShape)) + Spacer(Modifier.height(8.dp)) + Box( + Modifier.fillMaxWidth().height(40.dp) + .background(Color.Green, RoundedCornerShape(topStart = 20.dp, bottomEnd = 20.dp)), + ) + Spacer(Modifier.height(8.dp)) + Box( + Modifier.size(100.dp) + .border(3.dp, Color.DarkGray, RoundedCornerShape(16.dp)) + .background(Color.LightGray, RoundedCornerShape(16.dp)), + ) + } +} + +@Composable +fun CustomDrawingFixture() { + Canvas(Modifier.fillMaxSize().padding(24.dp)) { + drawCircle(color = Color.Red, radius = 40f, center = Offset(60f, 60f)) + drawCircle( + color = Color.Blue, radius = 40f, center = Offset(160f, 60f), + style = Stroke(width = 3f), + ) + drawRect(color = Color.Green, topLeft = Offset(10f, 120f), size = Size(100f, 60f)) + drawRect( + color = Color.DarkGray, topLeft = Offset(130f, 120f), size = Size(100f, 60f), + style = Stroke(width = 2f), + ) + drawLine(Color.Black, Offset(10f, 200f), Offset(250f, 200f), strokeWidth = 2f) + drawLine(Color.Red, Offset(10f, 210f), Offset(250f, 250f), strokeWidth = 1f) + drawArc( + color = Color.Blue, + startAngle = 0f, sweepAngle = 270f, + useCenter = true, + topLeft = Offset(10f, 270f), size = Size(80f, 80f), + ) + } +} + +@Composable +fun ComplexPathFixture() { + Canvas(Modifier.fillMaxSize().padding(24.dp)) { + // 5-pointed star + val starPath = Path().apply { + val cx = 100f + val cy = 100f + val outerR = 80f + val innerR = 35f + for (i in 0 until 10) { + val r = if (i % 2 == 0) outerR else innerR + val angle = kotlin.math.PI / 2 + i * kotlin.math.PI / 5 + val x = cx + (r * cos(angle)).toFloat() + val y = cy - (r * sin(angle)).toFloat() + if (i == 0) moveTo(x, y) else lineTo(x, y) + } + close() + } + drawPath(starPath, Color(0xFFFF6600)) + drawPath(starPath, Color.Black, style = Stroke(width = 2f)) + + // Cubic bezier curve + val bezierPath = Path().apply { + moveTo(10f, 250f) + cubicTo(80f, 180f, 180f, 320f, 260f, 250f) + } + drawPath(bezierPath, Color.Blue, style = Stroke(width = 3f)) + + // Quadratic bezier wave + val wavePath = Path().apply { + moveTo(10f, 350f) + quadraticTo(70f, 300f, 130f, 350f) + quadraticTo(190f, 400f, 250f, 350f) + } + drawPath(wavePath, Color(0xFF006400), style = Stroke(width = 2f)) + } +} + +@Composable +fun ClipShapesFixture() { + Column( + Modifier.fillMaxSize().padding(24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box( + Modifier.size(100.dp).clip(CircleShape).background(Color.Red), + contentAlignment = Alignment.Center, + ) { + Text("Circle", color = Color.White, fontSize = 12.sp) + } + Box( + Modifier.fillMaxWidth().height(60.dp) + .clip(RoundedCornerShape(16.dp)) + .background(Color.Blue), + contentAlignment = Alignment.Center, + ) { + Text("Rounded Clip", color = Color.White) + } + Box( + Modifier.size(120.dp, 80.dp) + .clip(PdfRoundedCornerShape(topStart = 24.dp, bottomEnd = 24.dp)) + .background(Color(0xFF6600FF)), + ) + // Nested clips + Box( + Modifier.size(100.dp) + .clip(RoundedCornerShape(20.dp)) + .background(Color.Yellow) + .padding(10.dp) + .clip(CircleShape) + .background(Color.Green), + ) + } +} + +// -- Layout -- + +@Composable +fun ColumnRowLayoutFixture() { + Column( + Modifier.fillMaxSize().padding(24.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text("Header", fontSize = 20.sp, fontWeight = FontWeight.Bold) + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text("Left") + Text("Center") + Text("Right") + } + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Box(Modifier.weight(1f).height(30.dp).background(Color.Red)) + Box(Modifier.weight(2f).height(30.dp).background(Color.Green)) + Box(Modifier.weight(1f).height(30.dp).background(Color.Blue)) + } + Column( + Modifier.fillMaxWidth().padding(16.dp).background(Color.LightGray), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text("Centered content") + Text("Inside padded box") + } + } +} + +@Composable +fun BordersVarietyFixture() { + Column( + Modifier.fillMaxSize().padding(24.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box(Modifier.fillMaxWidth().height(40.dp).border(1.dp, Color.Black)) + Box(Modifier.fillMaxWidth().height(40.dp).border(2.dp, Color.Red)) + Box( + Modifier.fillMaxWidth().height(40.dp) + .border(3.dp, Color.Blue, RoundedCornerShape(8.dp)), + ) + Box( + Modifier.fillMaxWidth().height(40.dp) + .border(4.dp, Color(0xFF006400), RoundedCornerShape(20.dp)), + ) + HorizontalDivider(color = Color.Black, thickness = 1.dp) + HorizontalDivider(color = Color.Red, thickness = 2.dp) + HorizontalDivider(color = Color.Blue, thickness = 3.dp) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Box(Modifier.size(60.dp).border(1.dp, Color.Black).background(Color.LightGray)) + Box( + Modifier.size(60.dp) + .border(2.dp, Color.DarkGray, RoundedCornerShape(12.dp)) + .background(Color.Cyan, RoundedCornerShape(12.dp)), + ) + Box( + Modifier.size(60.dp) + .border(3.dp, Color.Magenta, CircleShape) + .background(Color.Yellow, CircleShape), + ) + } + } +} + +@Composable +fun DenseGridFixture() { + val gridColors = listOf( + Color.Red, Color.Blue, Color.Green, Color.Yellow, + Color.Cyan, Color.Magenta, Color(0xFFFF6600), Color(0xFF6600FF), + ) + Column( + Modifier.fillMaxSize().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + for (row in 0 until 8) { + Row( + Modifier.fillMaxWidth().height(48.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { + for (col in 0 until 8) { + val colorIndex = (row + col) % gridColors.size + Box( + Modifier.weight(1f).fillMaxHeight().background(gridColors[colorIndex]), + contentAlignment = Alignment.Center, + ) { + Text( + "${row * 8 + col}", + fontSize = 8.sp, + color = Color.White, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + ) + } + } + } + } + } +} + +// -- Visual -- + +@Composable +fun ColorsBackgroundsFixture() { + Column(Modifier.fillMaxSize().padding(24.dp)) { + val colors = listOf( + Color.Red, Color.Green, Color.Blue, + Color.Yellow, Color.Cyan, Color.Magenta, + Color(0xFFFF6600), Color(0xFF6600FF), Color(0xFF00FF66), + ) + for (color in colors) { + Box( + Modifier.fillMaxWidth().height(24.dp).background(color), + ) { + Text( + "Color: ${color.hashCode()}", + fontSize = 10.sp, + color = Color.White, + modifier = Modifier.padding(start = 4.dp), + ) + } + Spacer(Modifier.height(2.dp)) + } + } +} + +@Composable +fun OpacityFixture() { + Box(Modifier.fillMaxSize().padding(24.dp)) { + Box(Modifier.size(200.dp).background(Color.White)) + Box( + Modifier.padding(start = 20.dp, top = 20.dp) + .size(120.dp) + .background(Color.Red.copy(alpha = 0.5f)), + ) + Box( + Modifier.padding(start = 60.dp, top = 60.dp) + .size(120.dp) + .background(Color.Blue.copy(alpha = 0.5f)), + ) + Box( + Modifier.padding(start = 40.dp, top = 100.dp) + .size(120.dp) + .background(Color.Green.copy(alpha = 0.3f)), + ) + } +} + +// -- Composite -- + +@Composable +fun InvoiceLikeFixture() { + Column(Modifier.fillMaxSize().padding(24.dp)) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column { + Text("INVOICE", fontSize = 28.sp, fontWeight = FontWeight.Bold, color = Color(0xFF333333)) + Text("#INV-2024-001", fontSize = 14.sp, color = Color.Gray) + } + Column(horizontalAlignment = Alignment.End) { + Text("Acme Corp", fontSize = 16.sp, fontWeight = FontWeight.Bold) + Text("123 Business St", fontSize = 12.sp, color = Color.Gray) + Text("contact@acme.com", fontSize = 12.sp, color = Color.Blue) + } + } + + Spacer(Modifier.height(16.dp)) + HorizontalDivider(color = Color.LightGray, thickness = 1.dp) + Spacer(Modifier.height(16.dp)) + + Row( + Modifier.fillMaxWidth().background(Color(0xFFF5F5F5)).padding(8.dp), + ) { + Text("Item", Modifier.weight(3f), fontWeight = FontWeight.Bold, fontSize = 12.sp) + Text("Qty", Modifier.weight(1f), fontWeight = FontWeight.Bold, fontSize = 12.sp) + Text("Price", Modifier.weight(1f), fontWeight = FontWeight.Bold, fontSize = 12.sp) + Text("Total", Modifier.weight(1f), fontWeight = FontWeight.Bold, fontSize = 12.sp) + } + + val items = listOf( + listOf("Widget Pro", "5", "\$10.00", "\$50.00"), + listOf("Gadget Basic", "2", "\$25.00", "\$50.00"), + listOf("Service Fee", "1", "\$100.00", "\$100.00"), + ) + for (item in items) { + Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp)) { + Text(item[0], Modifier.weight(3f), fontSize = 12.sp) + Text(item[1], Modifier.weight(1f), fontSize = 12.sp) + Text(item[2], Modifier.weight(1f), fontSize = 12.sp) + Text(item[3], Modifier.weight(1f), fontSize = 12.sp) + } + } + + Spacer(Modifier.height(8.dp)) + HorizontalDivider(color = Color.LightGray, thickness = 1.dp) + Spacer(Modifier.height(8.dp)) + + Row( + Modifier.fillMaxWidth().padding(8.dp), + horizontalArrangement = Arrangement.End, + ) { + Text("Total: ", fontWeight = FontWeight.Bold, fontSize = 16.sp) + Text("\$200.00", fontWeight = FontWeight.Bold, fontSize = 16.sp, color = Color(0xFF006400)) + } + } +} + +// -- Composite: PdfLink -- + +@Composable +fun PdfLinkFixture() { + Column( + Modifier.fillMaxSize().padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Plain text link + PdfLink(href = "https://example.com") { + Text( + "Visit Example.com", + fontSize = 16.sp, + color = Color.Blue, + textDecoration = TextDecoration.Underline, + ) + } + // Button-style link + PdfLink(href = "https://github.com") { + Box( + Modifier + .background(Color(0xFF2196F3), RoundedCornerShape(8.dp)) + .padding(horizontal = 24.dp, vertical = 12.dp), + ) { + Text("GitHub", color = Color.White, fontWeight = FontWeight.Bold) + } + } + // Multiple inline links + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + PdfLink(href = "https://a.com") { + Text("Link A", color = Color.Blue, fontSize = 14.sp) + } + PdfLink(href = "https://b.com") { + Text("Link B", color = Color.Red, fontSize = 14.sp) + } + PdfLink(href = "https://c.com") { + Text("Link C", color = Color(0xFF006400), fontSize = 14.sp) + } + } + // Large clickable area + PdfLink(href = "https://large-area.com") { + Box( + Modifier.fillMaxWidth().height(80.dp) + .background(Color(0xFFF0F0F0), RoundedCornerShape(12.dp)), + contentAlignment = Alignment.Center, + ) { + Text("Large Clickable Area", fontSize = 18.sp, color = Color.DarkGray) + } + } + } +} + +// -- Edge Cases -- + +@Composable +fun DeepNestingFixture() { + val colors = listOf( + Color(0xFFE53935), Color(0xFFFF9800), Color(0xFFFDD835), + Color(0xFF43A047), Color(0xFF1E88E5), Color(0xFF5E35B1), + Color(0xFFE91E63), Color(0xFF00ACC1), + ) + var content: @Composable () -> Unit = { + Text("Deep!", fontSize = 12.sp, color = Color.White, fontWeight = FontWeight.Bold) + } + for (i in colors.indices.reversed()) { + val inner = content + val color = colors[i] + content = { + Box( + Modifier + .fillMaxSize() + .padding(8.dp) + .clip(RoundedCornerShape((4 + i * 2).dp)) + .background(color) + .padding(4.dp), + contentAlignment = Alignment.Center, + ) { + inner() + } + } + } + Box(Modifier.fillMaxSize().padding(24.dp)) { + content() + } +} + +@Composable +fun OverlappingElementsFixture() { + Box(Modifier.fillMaxSize().padding(24.dp)) { + // White base + Box(Modifier.fillMaxSize().background(Color.White)) + // Three overlapping semi-transparent boxes + Box( + Modifier.padding(start = 20.dp, top = 40.dp) + .size(180.dp, 120.dp) + .background(Color.Red.copy(alpha = 0.6f), RoundedCornerShape(12.dp)), + ) + Box( + Modifier.padding(start = 80.dp, top = 80.dp) + .size(180.dp, 120.dp) + .background(Color.Blue.copy(alpha = 0.6f), RoundedCornerShape(12.dp)), + ) + Box( + Modifier.padding(start = 50.dp, top = 140.dp) + .size(180.dp, 120.dp) + .background(Color.Green.copy(alpha = 0.6f), RoundedCornerShape(12.dp)), + ) + // Text on top + Box( + Modifier.padding(start = 60.dp, top = 100.dp) + .background(Color.White.copy(alpha = 0.8f), RoundedCornerShape(4.dp)) + .padding(8.dp), + ) { + Text("Z-Order Test", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = Color.Black) + } + } +} + +@Composable +fun ColorBandsFixture() { + Column(Modifier.fillMaxSize().padding(16.dp)) { + Text("32 Color Bands", fontSize = 16.sp, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(8.dp)) + for (i in 0 until 32) { + val hue = i * (360f / 32f) + // HSL to RGB approximation + val c = hslToColor(hue, 0.7f, 0.5f) + Box( + Modifier.fillMaxWidth().height(12.dp).background(c), + ) + } + } +} + +@Composable +fun EmptyPageFixture() { + Box(Modifier.fillMaxSize()) { + // Nearly blank -- just a tiny 1dp marker in the top-left corner + Box( + Modifier.padding(start = 1.dp, top = 1.dp) + .size(1.dp) + .background(Color.Black), + ) + } +} + +// -- Real-World Documents -- + +@Composable +fun DetailedInvoiceFixture() { + Column(Modifier.fillMaxSize().padding(24.dp)) { + // Header + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Column { + Text("INVOICE", fontSize = 32.sp, fontWeight = FontWeight.Bold, color = Color(0xFF1A237E)) + Text("#INV-2024-0847", fontSize = 14.sp, color = Color.Gray) + Text("Date: March 15, 2024", fontSize = 12.sp, color = Color.Gray) + Text("Due: April 14, 2024", fontSize = 12.sp, color = Color.Red) + } + Column(horizontalAlignment = Alignment.End) { + Text("TechCorp Solutions", fontSize = 18.sp, fontWeight = FontWeight.Bold) + Text("742 Evergreen Terrace", fontSize = 11.sp, color = Color.Gray) + Text("Springfield, IL 62704", fontSize = 11.sp, color = Color.Gray) + Text("billing@techcorp.io", fontSize = 11.sp, color = Color(0xFF1565C0)) + } + } + Spacer(Modifier.height(12.dp)) + HorizontalDivider(color = Color(0xFF1A237E), thickness = 2.dp) + Spacer(Modifier.height(8.dp)) + // Bill To + Text("Bill To:", fontSize = 12.sp, fontWeight = FontWeight.Bold, color = Color.Gray) + Text("Acme Industries Ltd.", fontSize = 14.sp, fontWeight = FontWeight.Bold) + Text("456 Oak Avenue, Suite 200", fontSize = 11.sp) + Spacer(Modifier.height(12.dp)) + // Table header + Row(Modifier.fillMaxWidth().background(Color(0xFF1A237E)).padding(8.dp)) { + Text("Description", Modifier.weight(3f), color = Color.White, fontSize = 11.sp, fontWeight = FontWeight.Bold) + Text("Qty", Modifier.weight(0.7f), color = Color.White, fontSize = 11.sp, fontWeight = FontWeight.Bold) + Text("Rate", Modifier.weight(1f), color = Color.White, fontSize = 11.sp, fontWeight = FontWeight.Bold) + Text("Amount", Modifier.weight(1f), color = Color.White, fontSize = 11.sp, fontWeight = FontWeight.Bold) + } + // Line items + val items = listOf( + listOf("Web Application Development", "40", "$150.00", "$6,000.00"), + listOf("UI/UX Design Services", "20", "$120.00", "$2,400.00"), + listOf("Database Architecture", "15", "$175.00", "$2,625.00"), + listOf("QA Testing & Code Review", "10", "$100.00", "$1,000.00"), + listOf("Project Management", "8", "$130.00", "$1,040.00"), + ) + for ((idx, item) in items.withIndex()) { + val bg = if (idx % 2 == 0) Color(0xFFF5F5F5) else Color.White + Row(Modifier.fillMaxWidth().background(bg).padding(horizontal = 8.dp, vertical = 4.dp)) { + Text(item[0], Modifier.weight(3f), fontSize = 11.sp) + Text(item[1], Modifier.weight(0.7f), fontSize = 11.sp) + Text(item[2], Modifier.weight(1f), fontSize = 11.sp) + Text(item[3], Modifier.weight(1f), fontSize = 11.sp) + } + } + Spacer(Modifier.height(8.dp)) + HorizontalDivider() + // Totals + Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.End) { + Row(Modifier.width(200.dp), horizontalArrangement = Arrangement.SpaceBetween) { + Text("Subtotal:", fontSize = 12.sp) + Text("$13,065.00", fontSize = 12.sp) + } + Row(Modifier.width(200.dp), horizontalArrangement = Arrangement.SpaceBetween) { + Text("Discount (10%):", fontSize = 12.sp, color = Color(0xFF2E7D32)) + Text("-$1,306.50", fontSize = 12.sp, color = Color(0xFF2E7D32)) + } + Row(Modifier.width(200.dp), horizontalArrangement = Arrangement.SpaceBetween) { + Text("Tax (8.5%):", fontSize = 12.sp) + Text("$999.47", fontSize = 12.sp) + } + HorizontalDivider(Modifier.width(200.dp)) + Row(Modifier.width(200.dp), horizontalArrangement = Arrangement.SpaceBetween) { + Text("Total Due:", fontSize = 14.sp, fontWeight = FontWeight.Bold) + Text("$12,757.97", fontSize = 14.sp, fontWeight = FontWeight.Bold, color = Color(0xFF1A237E)) + } + } + Spacer(Modifier.height(12.dp)) + // Payment terms + Box(Modifier.fillMaxWidth().background(Color(0xFFFFF3E0), RoundedCornerShape(8.dp)).padding(12.dp)) { + Text("Payment Terms: Net 30. Please make checks payable to TechCorp Solutions or wire to account ending 4892.", fontSize = 10.sp, color = Color(0xFFE65100)) + } + } +} + +@Composable +fun ReceiptFixture() { + Column( + Modifier.fillMaxSize().padding(horizontal = 80.dp, vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text("COFFEE HOUSE", fontSize = 22.sp, fontWeight = FontWeight.Bold) + Text("123 Main Street", fontSize = 11.sp, color = Color.Gray) + Text("Tel: (555) 123-4567", fontSize = 11.sp, color = Color.Gray) + Spacer(Modifier.height(8.dp)) + // Dashed line simulation + Text("- - - - - - - - - - - - - - - - - - - - - - - -", fontSize = 10.sp, color = Color.Gray) + Spacer(Modifier.height(4.dp)) + Text("ORDER #4821", fontSize = 14.sp, fontWeight = FontWeight.Bold) + Text("Mar 20, 2024 2:34 PM", fontSize = 11.sp, color = Color.Gray) + Spacer(Modifier.height(8.dp)) + val receiptItems = listOf( + Triple("Cappuccino (Large)", "x2", "$9.50"), + Triple("Blueberry Muffin", "x1", "$3.75"), + Triple("Avocado Toast", "x1", "$8.95"), + Triple("Fresh OJ", "x1", "$4.50"), + Triple("Chocolate Croissant", "x2", "$7.00"), + ) + for ((item, qty, price) in receiptItems) { + Row(Modifier.fillMaxWidth().padding(vertical = 2.dp)) { + Text(item, Modifier.weight(2f), fontSize = 12.sp) + Text(qty, Modifier.weight(0.5f), fontSize = 12.sp, textAlign = TextAlign.Center) + Text(price, Modifier.weight(0.8f), fontSize = 12.sp, textAlign = TextAlign.End) + } + } + Spacer(Modifier.height(4.dp)) + Text("- - - - - - - - - - - - - - - - - - - - - - - -", fontSize = 10.sp, color = Color.Gray) + Row(Modifier.fillMaxWidth()) { + Text("Subtotal", Modifier.weight(1f), fontSize = 12.sp) + Text("$33.70", fontSize = 12.sp, fontWeight = FontWeight.Bold) + } + Row(Modifier.fillMaxWidth()) { + Text("Tax (7%)", Modifier.weight(1f), fontSize = 12.sp, color = Color.Gray) + Text("$2.36", fontSize = 12.sp, color = Color.Gray) + } + Row(Modifier.fillMaxWidth()) { + Text("Tip", Modifier.weight(1f), fontSize = 12.sp, color = Color.Gray) + Text("$5.00", fontSize = 12.sp, color = Color.Gray) + } + HorizontalDivider(thickness = 2.dp) + Row(Modifier.fillMaxWidth()) { + Text("TOTAL", Modifier.weight(1f), fontSize = 16.sp, fontWeight = FontWeight.Bold) + Text("$41.06", fontSize = 16.sp, fontWeight = FontWeight.Bold) + } + Spacer(Modifier.height(8.dp)) + Text("Paid with Visa **** 4242", fontSize = 11.sp, color = Color.Gray) + Spacer(Modifier.height(8.dp)) + // Barcode placeholder + Row(horizontalArrangement = Arrangement.Center) { + for (i in 0 until 30) { + val w = if (i % 3 == 0) 3.dp else 1.dp + Box(Modifier.width(w).height(40.dp).background(Color.Black)) + Spacer(Modifier.width(1.dp)) + } + } + Spacer(Modifier.height(4.dp)) + Text("Thank you for your visit!", fontSize = 12.sp, fontStyle = FontStyle.Italic) + } +} + +@Composable +fun ReportPageFixture() { + Column(Modifier.fillMaxSize().padding(24.dp)) { + Text("Q1 2024 Performance Report", fontSize = 22.sp, fontWeight = FontWeight.Bold) + Text("Prepared: March 31, 2024", fontSize = 11.sp, color = Color.Gray) + Spacer(Modifier.height(12.dp)) + // KPI Cards + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + val kpis = listOf( + Triple("Revenue", "$2.4M", Color(0xFF1565C0)), + Triple("Users", "184K", Color(0xFF2E7D32)), + Triple("Churn", "2.1%", Color(0xFFE65100)), + Triple("NPS", "72", Color(0xFF6A1B9A)), + ) + for ((label, value, color) in kpis) { + Box( + Modifier.weight(1f) + .background(color.copy(alpha = 0.1f), RoundedCornerShape(8.dp)) + .border(1.dp, color.copy(alpha = 0.3f), RoundedCornerShape(8.dp)) + .padding(12.dp), + ) { + Column { + Text(label, fontSize = 10.sp, color = Color.Gray) + Text(value, fontSize = 20.sp, fontWeight = FontWeight.Bold, color = color) + } + } + } + } + Spacer(Modifier.height(12.dp)) + // Bar chart simulation + Text("Monthly Revenue (K)", fontSize = 12.sp, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(4.dp)) + Row( + Modifier.fillMaxWidth().height(100.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.Bottom, + ) { + val bars = listOf(65f, 78f, 72f, 85f, 90f, 88f, 95f, 82f, 76f, 91f, 97f, 100f) + val months = listOf("J", "F", "M", "A", "M", "J", "J", "A", "S", "O", "N", "D") + for ((i, pct) in bars.withIndex()) { + Column( + Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + Modifier.fillMaxWidth() + .height((pct * 0.8f).dp) + .background(Color(0xFF1565C0), RoundedCornerShape(topStart = 2.dp, topEnd = 2.dp)), + ) + Text(months[i], fontSize = 8.sp, color = Color.Gray) + } + } + } + Spacer(Modifier.height(12.dp)) + // Data table + Text("Top Customers", fontSize = 12.sp, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(4.dp)) + Row(Modifier.fillMaxWidth().background(Color(0xFFE0E0E0)).padding(6.dp)) { + Text("Customer", Modifier.weight(2f), fontSize = 10.sp, fontWeight = FontWeight.Bold) + Text("Revenue", Modifier.weight(1f), fontSize = 10.sp, fontWeight = FontWeight.Bold) + Text("Growth", Modifier.weight(1f), fontSize = 10.sp, fontWeight = FontWeight.Bold) + } + val customers = listOf( + Triple("Acme Corp", "$342K", "+12%"), + Triple("Global Tech", "$298K", "+8%"), + Triple("Pinnacle Ltd", "$245K", "+22%"), + Triple("Atlas Industries", "$198K", "-3%"), + Triple("Nova Systems", "$176K", "+15%"), + ) + for ((name, rev, growth) in customers) { + Row(Modifier.fillMaxWidth().padding(horizontal = 6.dp, vertical = 3.dp)) { + Text(name, Modifier.weight(2f), fontSize = 10.sp) + Text(rev, Modifier.weight(1f), fontSize = 10.sp) + val growthColor = if (growth.startsWith("+")) Color(0xFF2E7D32) else Color.Red + Text(growth, Modifier.weight(1f), fontSize = 10.sp, color = growthColor) + } + HorizontalDivider(color = Color(0xFFEEEEEE)) + } + } +} + +@Composable +fun FormLayoutFixture() { + Column(Modifier.fillMaxSize().padding(24.dp)) { + Text("Patient Registration Form", fontSize = 20.sp, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(4.dp)) + Text("Please fill in all required fields (*)", fontSize = 11.sp, color = Color.Gray) + Spacer(Modifier.height(12.dp)) + + // Form section + Text("Personal Information", fontSize = 14.sp, fontWeight = FontWeight.Bold, color = Color(0xFF1565C0)) + HorizontalDivider(color = Color(0xFF1565C0)) + Spacer(Modifier.height(8.dp)) + + // Field rows + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + FormField("First Name *", "John", Modifier.weight(1f)) + FormField("Last Name *", "Doe", Modifier.weight(1f)) + } + Spacer(Modifier.height(8.dp)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + FormField("Date of Birth *", "1985-06-15", Modifier.weight(1f)) + FormField("Phone *", "(555) 867-5309", Modifier.weight(1f)) + } + Spacer(Modifier.height(8.dp)) + FormField("Email", "john.doe@email.com", Modifier.fillMaxWidth()) + Spacer(Modifier.height(8.dp)) + FormField("Address", "742 Evergreen Terrace, Springfield, IL 62704", Modifier.fillMaxWidth()) + + Spacer(Modifier.height(12.dp)) + Text("Insurance Information", fontSize = 14.sp, fontWeight = FontWeight.Bold, color = Color(0xFF1565C0)) + HorizontalDivider(color = Color(0xFF1565C0)) + Spacer(Modifier.height(8.dp)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + FormField("Provider", "BlueCross Shield", Modifier.weight(1f)) + FormField("Policy #", "BC-4492-7781", Modifier.weight(1f)) + } + Spacer(Modifier.height(8.dp)) + FormField("Group #", "GRP-88421", Modifier.fillMaxWidth(0.5f)) + + Spacer(Modifier.height(12.dp)) + // Checkboxes + Text("Reason for Visit", fontSize = 14.sp, fontWeight = FontWeight.Bold, color = Color(0xFF1565C0)) + HorizontalDivider(color = Color(0xFF1565C0)) + Spacer(Modifier.height(8.dp)) + val reasons = listOf("Annual Checkup" to true, "Follow-up" to false, "New Symptom" to true, "Referral" to false, "Lab Work" to false) + for ((reason, checked) in reasons) { + Row(Modifier.padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier.size(14.dp).border(1.dp, Color.DarkGray, RoundedCornerShape(2.dp)) + .background(if (checked) Color(0xFF1565C0) else Color.White, RoundedCornerShape(2.dp)), + contentAlignment = Alignment.Center, + ) { + if (checked) Text("X", fontSize = 10.sp, color = Color.White) + } + Spacer(Modifier.width(6.dp)) + Text(reason, fontSize = 12.sp) + } + } + + Spacer(Modifier.height(16.dp)) + Box( + Modifier.fillMaxWidth(0.3f) + .background(Color(0xFF1565C0), RoundedCornerShape(4.dp)) + .padding(horizontal = 16.dp, vertical = 8.dp), + contentAlignment = Alignment.Center, + ) { + Text("Submit Form", fontSize = 12.sp, color = Color.White, fontWeight = FontWeight.Bold) + } + } +} + +@Composable +fun FormField(label: String, value: String, modifier: Modifier) { + Column(modifier.then(Modifier.fillMaxWidth())) { + Text(label, fontSize = 10.sp, color = Color.Gray) + Box( + Modifier.fillMaxWidth() + .border(1.dp, Color(0xFFBDBDBD), RoundedCornerShape(4.dp)) + .padding(horizontal = 8.dp, vertical = 6.dp), + ) { + Text(value, fontSize = 12.sp) + } + } +} + +@Composable +fun TechnicalDiagramFixture() { + Canvas(Modifier.fillMaxSize().padding(24.dp)) { + val boxW = 140f + val boxH = 50f + + // Draw boxes + fun drawBox(x: Float, y: Float, label: String, color: Long) { + drawRoundRect( + color = Color(color), + topLeft = Offset(x, y), + size = Size(boxW, boxH), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(8f), + ) + drawRoundRect( + color = Color.Black, + topLeft = Offset(x, y), + size = Size(boxW, boxH), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(8f), + style = Stroke(width = 2f), + ) + } + + // Draw arrows (simple lines with arrowheads) + fun drawArrow(x1: Float, y1: Float, x2: Float, y2: Float) { + drawLine(Color.Black, Offset(x1, y1), Offset(x2, y2), strokeWidth = 2f) + // Arrowhead + val dx = x2 - x1 + val dy = y2 - y1 + val len = kotlin.math.sqrt(dx * dx + dy * dy) + val ux = dx / len + val uy = dy / len + val ax = x2 - ux * 10f + uy * 5f + val ay = y2 - uy * 10f - ux * 5f + val bx = x2 - ux * 10f - uy * 5f + val by = y2 - uy * 10f + ux * 5f + val arrowPath = Path().apply { + moveTo(x2, y2) + lineTo(ax, ay) + lineTo(bx, by) + close() + } + drawPath(arrowPath, Color.Black) + } + + // Flowchart layout + val centerX = (size.width - boxW) / 2 + drawBox(centerX, 10f, "Start", 0xFFE3F2FD) + drawBox(centerX, 100f, "Process A", 0xFFFFF3E0) + drawBox(centerX - 100f, 200f, "Decision", 0xFFE8F5E9) + drawBox(centerX + 100f, 200f, "Process B", 0xFFFCE4EC) + drawBox(centerX, 300f, "Merge", 0xFFF3E5F5) + drawBox(centerX, 390f, "End", 0xFFE3F2FD) + + // Arrows + drawArrow(centerX + boxW / 2, 60f, centerX + boxW / 2, 100f) + drawArrow(centerX + boxW / 2, 150f, centerX - 100f + boxW / 2, 200f) + drawArrow(centerX + boxW / 2, 150f, centerX + 100f + boxW / 2, 200f) + drawArrow(centerX - 100f + boxW / 2, 250f, centerX + boxW / 2, 300f) + drawArrow(centerX + 100f + boxW / 2, 250f, centerX + boxW / 2, 300f) + drawArrow(centerX + boxW / 2, 350f, centerX + boxW / 2, 390f) + + // Diamond for decision + val dcx = centerX - 100f + boxW / 2 + val dcy = 225f + val diamond = Path().apply { + moveTo(dcx, dcy - 20f) + lineTo(dcx + 30f, dcy) + lineTo(dcx, dcy + 20f) + lineTo(dcx - 30f, dcy) + close() + } + drawPath(diamond, Color(0xFF4CAF50), style = Stroke(width = 2f)) + + // Yes/No labels + drawCircle(Color.White, radius = 12f, center = Offset(centerX - 40f, 175f)) + drawCircle(Color.White, radius = 12f, center = Offset(centerX + boxW + 40f, 175f)) + } +} + +// -- Page Size Fixtures -- + +@Composable +fun LetterPageFixture() { + Column(Modifier.fillMaxSize().padding(24.dp)) { + Text("US Letter Format", fontSize = 24.sp, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(8.dp)) + Text("8.5 x 11 inches (215.9 x 279.4 mm)", fontSize = 14.sp, color = Color.Gray) + Spacer(Modifier.height(16.dp)) + Box( + Modifier.fillMaxWidth().height(60.dp) + .background(Color(0xFF1565C0), RoundedCornerShape(8.dp)), + contentAlignment = Alignment.Center, + ) { + Text("Header Section", color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Bold) + } + Spacer(Modifier.height(12.dp)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Box( + Modifier.weight(1f).height(120.dp) + .background(Color(0xFFE3F2FD), RoundedCornerShape(8.dp)) + .padding(12.dp), + ) { Text("Column A content with text wrapping to test layout on Letter paper.", fontSize = 12.sp) } + Box( + Modifier.weight(1f).height(120.dp) + .background(Color(0xFFFFF3E0), RoundedCornerShape(8.dp)) + .padding(12.dp), + ) { Text("Column B content verifying width calculations match Letter dimensions.", fontSize = 12.sp) } + } + Spacer(Modifier.height(12.dp)) + for (i in 1..5) { + Text("Paragraph $i: Standard body text line for Letter format fidelity testing.", fontSize = 12.sp) + Spacer(Modifier.height(4.dp)) + } + } +} + +@Composable +fun A3PageFixture() { + Column(Modifier.fillMaxSize().padding(32.dp)) { + Text("A3 Format Document", fontSize = 32.sp, fontWeight = FontWeight.Bold) + Text("297 x 420 mm -- Larger format for posters and presentations", fontSize = 16.sp, color = Color.Gray) + Spacer(Modifier.height(16.dp)) + // 3-column layout (A3 is wide enough) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + for (col in listOf("Overview", "Details", "Summary")) { + Box( + Modifier.weight(1f).height(200.dp) + .background(Color(0xFFF5F5F5), RoundedCornerShape(12.dp)) + .border(1.dp, Color(0xFFBDBDBD), RoundedCornerShape(12.dp)) + .padding(16.dp), + ) { + Column { + Text(col, fontSize = 18.sp, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(8.dp)) + Text( + "A3 provides extra width for multi-column layouts. This section " + + "verifies correct rendering at the larger page dimensions.", + fontSize = 13.sp, + ) + } + } + } + } + Spacer(Modifier.height(16.dp)) + // Wide data table (6 columns) + Row(Modifier.fillMaxWidth().background(Color(0xFF424242)).padding(8.dp)) { + for (header in listOf("ID", "Product", "Category", "Price", "Stock", "Status")) { + Text(header, Modifier.weight(1f), color = Color.White, fontSize = 12.sp, fontWeight = FontWeight.Bold) + } + } + val data = listOf( + listOf("001", "Widget Pro", "Hardware", "$49.99", "342", "Active"), + listOf("002", "Gadget X", "Electronics", "$129.00", "87", "Active"), + listOf("003", "Service Plan", "Subscription", "$9.99/mo", "1204", "Active"), + listOf("004", "Cable Kit", "Accessories", "$24.50", "0", "Out of Stock"), + ) + for ((idx, row) in data.withIndex()) { + val bg = if (idx % 2 == 0) Color(0xFFF5F5F5) else Color.White + Row(Modifier.fillMaxWidth().background(bg).padding(horizontal = 8.dp, vertical = 4.dp)) { + for (cell in row) { + Text(cell, Modifier.weight(1f), fontSize = 11.sp) + } + } + } + } +} + +// -- Helpers -- + +private fun hslToColor(h: Float, s: Float, l: Float): Color { + val c = (1f - kotlin.math.abs(2f * l - 1f)) * s + val x = c * (1f - kotlin.math.abs((h / 60f) % 2f - 1f)) + val m = l - c / 2f + val (r, g, b) = when { + h < 60f -> Triple(c, x, 0f) + h < 120f -> Triple(x, c, 0f) + h < 180f -> Triple(0f, c, x) + h < 240f -> Triple(0f, x, c) + h < 300f -> Triple(x, 0f, c) + else -> Triple(c, 0f, x) + } + return Color(r + m, g + m, b + m) +}