Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
b5d233e
test(loki-layout): pin list hanging-indent & table fixed-width geometry
claude Jun 24, 2026
674e12a
feat(import): parse drop caps & floating-image wrap (DOCX + ODT)
claude Jun 24, 2026
8b305bb
test(loki-acid): add render_acid_pdf example for Word-vs-Loki comparison
claude Jun 24, 2026
a9927ec
fix(loki-pdf): skip .notdef glyphs on export, matching loki-vello
claude Jun 24, 2026
133ca3a
fix(loki-layout): register metric-compatible fallback fonts on all pl…
claude Jun 24, 2026
5354d44
fix(import): resolve bare paragraphs through the default paragraph style
claude Jun 24, 2026
999fb24
feat(loki-layout): tab-stop alignment (right/center/decimal) + leaders
claude Jun 24, 2026
c4a46ae
fix(loki-layout): wrap over-long words within table cells (TC-DOCX-006)
claude Jun 24, 2026
40ff587
fix(loki-layout): place cells correctly across vMerge-covered columns
claude Jun 24, 2026
3b83f84
feat(tables): honour w:tblLayout="fixed" grid column widths
claude Jun 24, 2026
6996c5c
feat(tables): clip cell content to the cell box
claude Jun 24, 2026
383cbdd
feat(layout): render dropped initials (drop caps)
claude Jun 24, 2026
fec7474
feat(layout): wrap text around floating images (gap #12)
claude Jun 24, 2026
024bac9
refactor(layout): extract emit_glyph_run into a shared helper
claude Jun 24, 2026
3250ad5
feat(layout): per-line precision for drop caps and float wrap
claude Jun 24, 2026
163e6d4
feat(layout): cross-paragraph float wrap
claude Jun 25, 2026
98f8f20
feat(layout): render drop caps in the live editor with hit-testing
claude Jun 25, 2026
d66b917
test(acid): render ODT fixtures + verify ODT drop-cap path
claude Jun 25, 2026
75da5fa
feat(odf): float ODT frames; add TC-ODT-007 fixture case
claude Jun 25, 2026
7672dc1
chore(odf): silence cross-binary dead-code + tidy test lints
claude Jun 25, 2026
3735407
feat(epub): flow text around floating images via CSS float
claude Jun 25, 2026
93b75b1
fix(editor): place the caret on touch in the non-paginated view
claude Jun 25, 2026
24da82e
fix(shell): re-summon the soft keyboard on every tap, not just the first
claude Jun 25, 2026
aecf712
feat(shell): hover-tooltip overlay painted outside Blitz
claude Jun 25, 2026
ca9c08b
Inset safe area by soft-keyboard height on Android
claude Jun 26, 2026
7ca2b4f
Wire soft-keyboard safe area + tooltips into Calc and Slides
claude Jun 26, 2026
32ec667
Fix per-page chrome, pattern shading, and scaled-width text; align apps
claude Jun 26, 2026
96d2acf
Add roman/alpha page-number formats (DOCX) and exact-line clipping
claude Jun 26, 2026
52ca053
Import nested tables in DOCX cells
claude Jun 26, 2026
be6ad74
Update fidelity-status for shading, scale, page numbers, exact-clip, …
claude Jun 26, 2026
4f1919d
Bring ODT to parity: nested tables + page-number format
claude Jun 26, 2026
f44893b
Render highlights, small caps, and Word-accurate exact-line clipping
claude Jun 26, 2026
e8e132e
Render w:position + per-glyph scale; serif/centered display math
claude Jun 26, 2026
1a066fe
Support continuous section breaks (share a page, switch columns mid-p…
claude Jun 27, 2026
f6ce3b5
Register bundled UI fonts synchronously so they load on Android
claude Jun 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ lines). The full list and a proposed split strategy live in
offenders are below. This is a dedicated split-pass backlog, not a per-change
blocker — but do not *grow* these files or add new ones over the ceiling.

The split pass is **in progress** — **10 of 43 files done** (≈33 remain). Two
The split pass is **in progress** — **11 of 43 files done** (≈32 remain). Two
techniques:
1. *Inline-test extraction* (safest, no production-code change): move a file's
`#[cfg(test)] mod tests { … }` into a sibling `<name>_tests.rs` referenced via
Expand All @@ -141,9 +141,13 @@ techniques:
`odt/mapper/lists.rs`, `layout/result.rs`, `renderer/render_layout.rs` — each
was over the ceiling only because of a large inline test module.
2. *Directory split*: convert `foo.rs` → a `foo/` directory with section-cohesive
submodules (each starting `use super::*;`), re-export the public entry points
from `foo/mod.rs`, and move the tests via the same `#[path]` idiom. Done for
`odt/mapper/props.rs` → `odt/mapper/props/` (worked example).
submodules, re-export the public entry points from `foo/mod.rs`, and move the
tests via the same `#[path]` idiom. Give each submodule its **own explicit
`use` list** (importing siblings via `use super::sibling::fn`) — `use super::*`
trips `clippy::wildcard_imports`. Done for `odt/mapper/props.rs` →
`odt/mapper/props/` and `odt/mapper/document.rs` →
`odt/mapper/document/` (`mod`/`inlines`/`frames`/`blocks`/`page`/`meta`; worked
examples).

(Test files are exempt from the production-line count.)

Expand All @@ -156,10 +160,12 @@ techniques:
| `loki-spreadsheet/src/routes/editor/editor_inner.rs` | 1241 | High |
| `loki-ooxml/src/docx/write/document.rs` | 1169 | High |
| `loki-ooxml/src/docx/reader/document.rs` | 1126 | High |
| `loki-odf/src/odt/mapper/document.rs` | 1094 | High |
| `loki-text/src/routes/editor/editor_inner.rs` | 968 | High |
| … 24 more (300–600 lines) — see the audit (10 files split 2026-06-21) | | |

(`odt/mapper/document.rs` (1094 lines) was split into the `odt/mapper/document/`
directory on 2026-06-26 — each module is now under the ceiling.)

(`read.rs` was split into `read.rs` + `props_read.rs`; both are now under 300
lines. `loro_bridge/inlines.rs` is now 219 lines, under the ceiling.
`loki-text/src/components/document_source.rs` no longer exists.)
Expand Down
4 changes: 4 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions appthere-ui/src/components/ribbon/button.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ pub fn AtRibbonIconButton(
fg = icon_color,
),
aria_label: aria_label.clone(),
// The hover tooltip overlay (blitz-shell) reads `title`; reuse the
// accessible name so every icon button is self-describing on hover.
title: aria_label.clone(),
aria_pressed: if is_active { "true" } else { "false" },
disabled: is_disabled,
onmouseenter: move |_| hovered.set(true),
Expand Down
8 changes: 5 additions & 3 deletions appthere-ui/src/safe_area.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@
//!
//! The insets are **not** fixed for the lifetime of the app: on Android they
//! change with orientation (in landscape the navigation bar / cutout move to a
//! side). Call [`update_safe_area_insets`] from within the Dioxus runtime (e.g.
//! a resize handler) to push new values — readers of [`use_safe_area`]
//! re-render so the padding follows the current orientation.
//! side) and with the soft keyboard (the bottom inset grows by the on-screen
//! keyboard / IME height while it is visible, so content is pushed above it).
//! Call [`update_safe_area_insets`] from within the Dioxus runtime (e.g. a
//! resize handler, or the keyboard-driven re-sync) to push new values — readers
//! of [`use_safe_area`] re-render so the padding follows the current state.
//!
//! On desktop platforms nothing ever updates the value, so [`use_safe_area`]
//! returns all-zero insets and the root padding is effectively a no-op.
Expand Down
69 changes: 57 additions & 12 deletions docs/fidelity-status.md

Large diffs are not rendered by default.

96 changes: 90 additions & 6 deletions docs/patches.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,37 @@ focusable `<div inputmode="text">`, so tapping it raises the keyboard while
tapping a ribbon `<button>` (focusable, but not a text target) lowers it. An
`ime_active: bool` field debounces redundant winit calls.

**Soft-keyboard re-trigger on tap (PATCH(loki), 2026-06-25):** the OS never
reports when the user dismisses the soft keyboard (Android back / swipe-down),
so `ime_active` stays `true` and a second tap to reposition the caret in the
already-focused canvas would never bring it back. `update_ime_for_focus` now
takes a `force_show` flag: a focus change still toggles IME on/off, but a fresh
pointer release (mouse-up / touch tap) on a text surface re-issues
`set_ime_allowed(true)` even when IME is already active, re-summoning a
dismissed keyboard. (If a future winit dedupes same-value `set_ime_allowed`
calls, switch the force path to a `false`→`true` toggle.)

**Soft-keyboard safe-area re-sync (PATCH(loki), 2026-06-26):** the app runs in a
stock `NativeActivity` with no `windowSoftInputMode`, so the GL surface is **not**
resized when the soft keyboard appears — winit never fires `Resized` and the
keyboard overlays the bottom of the app (ribbon / bottom-of-document content).
winit / Blitz / Dioxus surface no IME-visibility or height events, but we already
drive the keyboard ourselves via `set_ime_allowed`, so a visibility change is our
cue to re-reserve the bottom safe area. When `update_ime_for_focus` shows, hides
or force-re-shows the keyboard, it calls `arm_ime_settle`, which opens a bounded
settle window (`IME_INSET_SETTLE`, 400 ms) and wakes the idle event loop at
60/160/280/400 ms via `BlitzShellEvent::Poll`. While `ime_settle_until` is in the
future, `poll` calls `resync_scroll_geometry`, which re-dispatches `onscroll`; the
app's hidden `SafeAreaResizeSensor` catches that tick and re-queries
`query_window_insets_dp` — whose mask now includes `WindowInsets.Type.ime()`, so
the returned `bottom` grows to the keyboard height. The settle window exists
because Android reports/animates the IME inset a frame or two after the
visibility request (it is the real animation duration, not an arbitrary sleep).
Limitation: a system-back / swipe-down dismissal is not reported by the OS, so
the expanded bottom padding (harmless chrome, no content overlap) persists until
the next focus change or tap re-syncs — the same OS gap the re-trigger note above
documents. Android-only; on desktop `ime_settle_until` stays `None`.

**Scroll re-sync on resize (PATCH(loki), 2026-06-12):** `resync_scroll_geometry`
calls `doc.resolve()` and then re-dispatches `onscroll` (via
`collect_scroll_containers` + `handle_scroll_changes`) to every scroll container
Expand Down Expand Up @@ -182,6 +213,26 @@ touch variants.

**Added:** 2026-05-08

**Hover tooltip overlay (PATCH(loki), 2026-06-25):** Blitz/Stylo do not support
`position: absolute`/`fixed`, so a hover tooltip cannot be a DOM element (see the
COMPAT note in `appthere-ui/.../ribbon/button.rs`). Instead the shell paints the
tooltip **into the Vello scene itself**, after `paint_scene`, entirely outside
the DOM (`src/tooltip.rs` + `View::render_scene`). On `CursorMoved` the shell
hit-tests the node under the cursor (`doc.hit`) and walks ancestors for a `title`
attribute; a new titled element arms a delayed show (`HOVER_DELAY` = 500 ms) by
spawning a one-shot thread that sends `BlitzShellEvent::Poll` at the deadline
(the loop is `ControlFlow::Wait`, so a stationary cursor produces no other
wake). `poll` flips the tooltip visible and requests a redraw; `render_scene`
then shapes the label with a self-contained parley `FontContext` (`()` brush,
generic sans-serif) and draws a shadow + rounded-rect + glyphs via
`PaintScene::{draw_box_shadow, fill, draw_glyphs}`, mirroring `blitz-paint`'s
glyph bridge so `run.font()` matches the `peniko::FontData` `draw_glyphs`
expects (hence the pinned `parley 0.6` / `peniko 0.5` / `kurbo 0.12` deps).
Click / scroll / keypress / touch clear it. The app side just adds a `title`
attribute (the ribbon icon button reuses its `aria_label`). **Removal
condition:** remove when Blitz supports `position: absolute`/`fixed` so tooltips
can be real DOM nodes.

---

### blitz-net — 0.2.1
Expand Down Expand Up @@ -283,6 +334,18 @@ a document opens, without clicking first; this re-enables that intended
behaviour. When re-vendoring the manifest during a Dioxus upgrade, preserve this
addition (it is a loki customisation, like the Android `softbuffer` deps).

**Bundled-font pre-registration (PATCH(loki), 2026-06-27).** `Config` gains a
`font_blobs: Vec<Vec<u8>>` field and a `with_fonts(..)` builder; `launch_cfg_with_props`
moves those blobs into `DocumentConfig.extra_fonts` (see the blitz-dom entry) so
the renderer registers them into its parley `FontContext` synchronously at
startup. The apps pass `loki_fonts::ui_font_blobs()` (the Atkinson Hyperlegible
Next UI typeface plus the metric-compatible fallback families). This replaces the
previous approach of injecting an `@font-face` `data:` URI via `document::Style`,
which relied on the asynchronous network-provider resource fetch and did not load
the UI typeface on Android (the chrome fell back to a wide system font). The
bytes are known at compile time, so synchronous registration is the correct
layer. Preserve this `Config`/launch customisation when re-vendoring.

**Root cause:** Upstream assumed OS-level redraw events would cover the
CSS-application step; this assumption holds on desktop but not on Android.
Upstream also leaves `onmounted` / `MountedData` unimplemented for native.
Expand Down Expand Up @@ -351,8 +414,20 @@ per-side insets, so landscape — where the navigation bar / cutout move to a si
the **Activity** (passed in via `AndroidApp::activity_as_ptr()`), since
`ndk_context` holds the `Application`, which has no window. Returns `None`
(caller falls back to `query_insets_dp`) before the view is attached or on
API < 30. loki-text re-queries it on resize via a hidden scroll-container
sensor and pushes the result into `appthere_ui::update_safe_area_insets`.
API < 30. Each Loki app (loki-text, loki-spreadsheet, loki-presentation)
re-queries it on resize via a hidden scroll-container sensor and pushes the
result into `appthere_ui::update_safe_area_insets`.

**Extends (PATCH(loki), 2026-06-26):** the mask now also includes
`WindowInsets.Type.ime()`, i.e.
`getInsets(systemBars | displayCutout | ime)`. Because `getInsets` returns the
per-side union and `ime()` contributes only a bottom inset, top/left/right are
unchanged while the keyboard is hidden, and `bottom` grows to the soft-keyboard
height while it is visible. Combined with the blitz-shell IME-settle re-sync
(see the blitz-shell section), this is what reserves a bottom safe area for the
keyboard on a `NativeActivity` whose surface does not resize. `ime()` is API 30+
— the same level as `getInsets(int)` — so the existing `None` fallback already
covers older API levels.

**Root cause:** loki-file-access 0.1.2 was designed for desktop and WASM; the
Android implementation was scaffolded but never exercised on a real NativeActivity
Expand Down Expand Up @@ -431,16 +506,25 @@ file picking additionally requires a Gradle build with `FilePickerActivity.kt`.
updates correct while idle frames stop. (`is_animating()` is left intact for
any other consumer.)

7. **Embedder-supplied font blobs (PATCH(loki), 2026-06-27).** `DocumentConfig`
gains `extra_fonts: Vec<Vec<u8>>`; `BaseDocument::new` registers each blob into
the parley `FontContext` (on top of the system fonts and the default bullet
font) at construction. This lets an app bundle its UI/fallback fonts and have
them resolve **synchronously** on every platform, instead of relying on the
asynchronous `@font-face` `data:` URI resource-fetch path (which did not load
the UI typeface on Android). `dioxus-native`'s `Config::with_fonts(..)` feeds
this field; the Loki apps pass `loki_fonts::ui_font_blobs()`.

**Removal condition:** Upstream blitz-dom implements tabindex focus-on-click
for non-input elements, dispatches scroll events to embedders, exposes an
absolute node-scroll API, and stops treating a static canvas as perpetually
animating (e.g. a per-source "needs animation" signal).

**Added:** 2026-05-18 (focus); extended 2026-06-10 (scroll events),
2026-06-11 (absolute scroll), and 2026-06-21 (`needs_animation_tick` — stop the
idle canvas redraw loop, paired with the blitz-shell `redraw()` change),
together with matching changes in the blitz-shell and dioxus-native(-dom)
patches.
2026-06-11 (absolute scroll), 2026-06-21 (`needs_animation_tick` — stop the
idle canvas redraw loop, paired with the blitz-shell `redraw()` change), and
2026-06-27 (`extra_fonts` — synchronous bundled-font registration), together
with matching changes in the blitz-shell and dioxus-native(-dom) patches.

---

Expand Down
2 changes: 2 additions & 0 deletions loki-acid/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,5 @@ loki-doc-model = { path = "../loki-doc-model" }
loki-graphics = { path = "../loki-graphics", features = ["serde"] }
loki-presentation-model = { path = "../loki-presentation-model" }
loki-primitives = { path = "../loki-primitives" }
# The `render_acid_pdf` example renders fixtures to PDF for visual comparison.
loki-pdf = { path = "../loki-pdf" }
Binary file modified loki-acid/assets/acid_odt.odt
Binary file not shown.
41 changes: 41 additions & 0 deletions loki-acid/examples/render_acid_pdf.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 AppThere Loki contributors

//! Render an ACID fixture through Loki's own pipeline to a PDF, for visual
//! comparison against a canonical (Word / LibreOffice) render.
//!
//! Usage:
//! cargo run -p loki-acid --example render_acid_pdf -- <in.docx|in.odt> <out.pdf>
//!
//! Reuses `loki-layout` for pagination (the same engine the editor/GPU renderer
//! use), so the PDF geometry matches Loki's on-screen layout. GPU-free.

use std::io::Cursor;

use loki_doc_model::Document;
use loki_odf::OdtImport;
use loki_ooxml::docx::import::{DocxImportOptions, DocxImporter};

fn main() {
let args: Vec<String> = std::env::args().collect();
if args.len() != 3 {
eprintln!("usage: render_acid_pdf <in.docx|in.odt> <out.pdf>");
std::process::exit(2);
}
let bytes = std::fs::read(&args[1]).expect("read input");
let doc: Document = if args[1].to_ascii_lowercase().ends_with(".odt") {
use loki_doc_model::io::DocumentImport;
OdtImport::import(Cursor::new(bytes), Default::default()).expect("import odt")
} else {
DocxImporter::new(DocxImportOptions::default())
.run(Cursor::new(bytes))
.expect("import docx")
.document
};

let mut out = Vec::new();
loki_pdf::export_document(&doc, &loki_pdf::PdfXOptions::default(), &mut out)
.expect("export pdf");
std::fs::write(&args[2], &out).expect("write pdf");
eprintln!("wrote {} ({} bytes)", args[2], out.len());
}
Loading
Loading