Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ The release run heads these entries with the version and opens a fresh
default, also in the bindings) turns this off.
- The line numbers of a text file are in each line, so a wrapped line keeps
its number beside it.
- `HtmlConfig::lazy_loading` (off by default, also in the bindings) writes
each chunk of a text file after the first as a script beside the page,
which the page loads when the reader comes near. `bring_offline` writes
the scripts, and the HTTP server serves them. A text file of 1 M lines is
then a page of 485 KB. `odr.search` finds only the loaded chunks, and a
print shows a chunk that has not loaded as empty space.

### Spreadsheets

Expand Down
2 changes: 2 additions & 0 deletions apple/include/OdrCoreObjC/ODRHtml.h
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ NS_SWIFT_NAME(HtmlConfig)
NSNumber *initialZoom NS_REFINED_FOR_SWIFT;
/// Lay out only the lines, rows and pages near the viewport.
@property(nonatomic) BOOL lazyLayout;
/// Load the chunks of a text file after the first as the reader comes near.
@property(nonatomic) BOOL lazyLoading;

/// The least distance the generated content keeps from the view's border. A
/// set side raises the inset the view already has, never lowers it.
Expand Down
2 changes: 2 additions & 0 deletions apple/src/ODRHtml.mm
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ - (instancetype)initWithNativeConfig:(const odr::HtmlConfig &)config {
_initialZoom =
config.initial_zoom.has_value() ? @(*config.initial_zoom) : nil;
_lazyLayout = config.lazy_layout ? YES : NO;
_lazyLoading = config.lazy_loading ? YES : NO;
_formatHtml = config.format_html ? YES : NO;
_htmlIndent = config.html_indent;
_htmlIndentString = to_nsstring(config.html_indent_string);
Expand Down Expand Up @@ -225,6 +226,7 @@ - (instancetype)initWithNativeConfig:(const odr::HtmlConfig &)config {
config.initial_zoom.reset();
}
config.lazy_layout = _lazyLayout == YES;
config.lazy_loading = _lazyLoading == YES;
if (_minContentMargin != nil) {
config.min_content_margin = _minContentMargin.handle;
} else {
Expand Down
3 changes: 3 additions & 0 deletions apple/tests/OdrCoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,9 @@ final class HtmlTests: XCTestCase {
XCTAssertTrue(config.lazyLayout)
config.lazyLayout = false
XCTAssertFalse(config.lazyLayout)
XCTAssertFalse(config.lazyLoading)
config.lazyLoading = true
XCTAssertTrue(config.lazyLoading)
}

func testBoxedNumbersRoundTrip() throws {
Expand Down
114 changes: 67 additions & 47 deletions docs/design/lazy-loading.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Lazy loading design

Status: level 1 has landed for text files (#1247) and pdf (#1248). The
rest is a proposal. Issue #764.
Status: level 1 has landed for text files (#1247), pdf (#1248) and sheets
(#1250), and level 3 for text files. The rest is a proposal. Issue #764.

Related: [`editing.md`](editing.md) holds the editing mode, which a page that
drops content must keep working. [`txt-editing.md`](txt-editing.md) holds the
Expand Down Expand Up @@ -75,15 +75,15 @@ the registry and the styles about 230 MB. The cell children of #781 are about
|---|---|---|
| **1. Containment** | The view writes its content in chunks. A chunk has `content-visibility: hidden` until a script marks it near the viewport, so the browser lays out only those. Without the script, a chunk has `auto`. | every page, offline too |
| **2. A window** | The page keeps a chunk far from the viewport as a string, not as DOM, and puts it back as the reader comes near. | every page, offline too |
| **3. On demand** | The page asks the host for a chunk, and core renders only that chunk. | a host that answers the request: the HTTP server, or a binding with a callback |
| **3. On demand** | The page loads a chunk as a script when the reader comes near, and core renders only that chunk. | the HTTP server, which renders a chunk when it is asked, and a file that `bring_offline` writes, with every chunk beside it |

**Why levels:** level 1 is CSS and a few lines of script, and it keeps the
whole DOM. So `odr.search`, the editor and a copy of a selection see all of
the content. The find of the browser and a screen reader do not see a
hidden chunk. Level 2 removes the DOM cost too, but takes more away
(decision 5). Level 3 is the only level that makes core cheaper, and it needs
a host. A view takes the lowest level that is enough
for the size of its file.
(decision 5). Level 3 is the only level that makes core cheaper, where a
server renders a chunk only when the page asks for it. A view takes the
lowest level that is enough for the size of its file.

`HtmlConfig::lazy_layout` turns level 1 off. Then no view writes a chunk,
and the find of the browser and a screen reader reach all of the content.
Expand All @@ -94,11 +94,11 @@ flat around it.
18. A browser without it ignores the property and lays out every chunk, as
it lays out every line today.

**Offline and server mode:** levels 1 and 2 work in a file that
`bring_offline` writes, because the file carries all the content. Only
level 3 needs a server or a host callback. So most of the work helps an
offline file too. What only a server adds is that core does not render,
hold or send the whole document at the start.
**Offline and server mode:** every level works in a file that
`bring_offline` writes. At level 3 the file has its chunks beside it, so
the page stays small and loads only what the reader comes near. What only
a server adds is that core does not render or send the whole document at
the start.

### 2. A chunk is the unit, with a stable index and a size

Expand All @@ -119,40 +119,53 @@ position stays where it is when the chunk arrives:
- A block of a text document has no size before layout. Text documents come
last, and may take only the page shape of `text_document_margin`.

### 3. The page reads chunks through one source
### 3. A chunk that loads is a script of its own

```js
// what level 2 and level 3 splice from
odr.chunks.source = {
count: 3907,
get: function (index) {}, // a Promise of the chunk's html
};
```html
<div class="odr-text-lines odr-chunk" data-odr-chunk="12"
data-odr-chunk-src="text-chunks/12.js"></div>
```

- **In the page (level 2):** each chunk is the text of a
`<script type="text/x-odr-chunk">`. The parser keeps a text node and builds
no DOM for it. A `<template>` would build an inert DOM, which costs the
memory that level 2 saves.
- **From a host (level 3):** `get` asks the HTTP server for a chunk path, or
calls a function that the binding installs.

The splice and the eviction are one script, whatever the source is.

**Why one source:** the bindings render into a web view and only some run a
server. So the chunk API of core is the primitive, and HTTP is one way to
carry it.

### 4. Core writes a chunk of a view

`HtmlView` gets the number of chunks and a write of one chunk. The view
writes the skeleton with a placeholder of the right size for each chunk.
`HttpServer` serves a chunk under a path of its view. `bring_offline` still
writes all of the content.
```js
// text-chunks/12.js
odr.chunks.fill(12, "<div style=\"counter-reset:odr-line 3072\">…</div>…");
```

At level 3 the service keeps the decoded document between requests. It does
so already: an `HtmlService` holds its document for its whole life. What
level 3 adds is the engine work, so that a chunk costs in proportion to the
chunk and not to the document (#781 for ods, the issue #764 for the rest).
When `viewport.js` marks such a chunk near, it adds a `<script>` with the
source, and the script puts the lines in through `odr.chunks.fill`. Until
then the chunk holds the height of its lines (`min-height`), so the page
does not move when they come.

**Why a script, not `fetch`:** a script loads over HTTP and from a file
alike, where `fetch` of a `file://` URL is blocked. So one page works in
both places, and a binding that renders into a web view from files needs
no callback of its own. The web view must let the page read the files
beside it, as it already must for the linked css and js.

**What stays in the page:** the first chunk, so the first frame needs no
request. An editable render keeps every line in the page, because its log
states the whole text. A page that the browser does not read as UTF-8
keeps them too, because a script is UTF-8.

### 4. Core writes a chunk as a resource of its view

The view returns each chunk as an `HtmlResource` whose `write_resource`
renders that chunk on demand. `bring_offline` writes them beside the page,
and `HttpServer` serves them through `HtmlService::exists` and `write`,
with no route of its own. `HtmlConfig::lazy_loading` turns it on, and it
needs `lazy_layout`.

The service keeps the decoded document between requests. It does so
already: an `HtmlService` holds its document for its whole life. The text
service also keeps the text and where each chunk starts in it, so a chunk
costs in proportion to the chunk. For the other engines, level 3 needs the
engine work that makes a chunk cost in proportion to the chunk and not to
the document (#781 for ods, the issue #764 for the rest).

**Measured:** the text file of one million lines is a page of 485 KB and
3906 scripts of about 24 KB. Over `file://` it paints its first frame
after 67 ms (level 1: 0.85 s). The HTTP server answers the page in 4 ms
and a chunk in 2 ms.

### 5. What level 2 changes for the reader, and the answer

Expand Down Expand Up @@ -258,10 +271,9 @@ the limits can go up is the next measurement.
line as a CSS counter, so the gutter needs no `ResizeObserver` and wraps
with its line. A chunk has style containment, so a counter does not count
on past it. The first line of each chunk sets the number it starts at.
2. **Text files, level 3.** The chunk source, the chunk write of core and
the HTTP route. A text file needs no engine work, so it proves the API.
Level 2 gains little for a text file: a window of strings painted in
0.5 s with 1.2 GB, and the hidden chunks in 0.85 s with 0.9 GB.
2. **Text files, level 3** (decisions 3 and 4). Level 2 gains little for
a text file: a window of strings painted in 0.5 s with 1.2 GB, and the
hidden chunks in 0.85 s with 0.9 GB.
3. **pdf, level 1.** A page has its size, so each page is a chunk of
exactly the right size. The observer moves into `viewport.js`, which
every view loads.
Expand All @@ -274,12 +286,20 @@ the limits can go up is the next measurement.

## Open questions

- **Print at level 3.** A chunk that has not loaded prints as empty space
of its height. `beforeprint` cannot wait for a script to load.
- **Fragments over HTTP.** A chunk is a script, because only a script loads
from `file://`. Over HTTP, the server could also serve a chunk as a plain
HTML fragment (`text-chunks/12.html`), and `viewport.js` could `fetch` it
when the page is not on `file://`. The script stays the fallback. Only
`write_chunk` and `load()` know the format.
- **The size of a chunk.** 256 lines is a guess. It trades the count of
chunks against the cost of one.
- **When a view takes level 2.** A threshold on the size of the content, or
a switch in `HtmlConfig`.
- **Search at level 3.** The page holds no strings for content it never
received. Search then needs the host.
- **Search at level 3.** `odr.search` searches the DOM, so it finds only
the chunks that have loaded. It needs the text of the others, from the
host or from the scripts.
- **The find of the browser.** `hidden="until-found"` lets it reach a hidden
chunk and show it. But a browser without that value hides the chunk for
good, where a browser without `content-visibility` shows it.
Expand Down
2 changes: 2 additions & 0 deletions jni/java/app/opendocument/core/HtmlConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ public final class HtmlConfig {
public Double initialZoom;
/** Lay out only the lines, rows and pages near the viewport. */
public boolean lazyLayout = true;
/** Load the chunks of a text file after the first as the reader comes near. */
public boolean lazyLoading = false;

/**
* The least distance the generated content keeps from the view's border. A set side raises the
Expand Down
2 changes: 2 additions & 0 deletions jni/src/jni_style.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -715,6 +715,7 @@ jobject html_config_to_java(JNIEnv *env, const odr::HtmlConfig &config) {
set_object("initialZoom", "Ljava/lang/Double;",
box_double(env, config.initial_zoom));
set_boolean("lazyLayout", config.lazy_layout);
set_boolean("lazyLoading", config.lazy_loading);
set_object("minContentMargin", "Lapp/opendocument/core/DirectionalMeasure;",
make_directional_measure(env, config.min_content_margin));
set_boolean("formatHtml", config.format_html);
Expand Down Expand Up @@ -915,6 +916,7 @@ odr::HtmlConfig html_config_from_java(JNIEnv *env, jobject config) {
env->DeleteLocalRef(zoom);
}
result.lazy_layout = get_boolean("lazyLayout");
result.lazy_loading = get_boolean("lazyLoading");
{
jobject margin = get_object("minContentMargin",
"Lapp/opendocument/core/DirectionalMeasure;");
Expand Down
3 changes: 3 additions & 0 deletions jni/tests/app/opendocument/core/HtmlTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ void htmlConfigDefaults() {
assertNull(config.viewportWidth);
assertNull(config.initialZoom);
assertTrue(config.lazyLayout);
assertFalse(config.lazyLoading);
}

@Test
Expand Down Expand Up @@ -100,6 +101,7 @@ void viewportConfigRoundTrips() throws IOException {
config.viewportWidth = 420;
config.initialZoom = 1.5;
config.lazyLayout = false;
config.lazyLoading = true;
config.sheetEditOnClick = false;
config.pdfAnnotationMarkOnSelection = true;
config.hostMessageHandler = "reader.postMessage";
Expand All @@ -113,6 +115,7 @@ void viewportConfigRoundTrips() throws IOException {
assertEquals(Integer.valueOf(420), readBack.viewportWidth);
assertEquals(Double.valueOf(1.5), readBack.initialZoom);
assertFalse(readBack.lazyLayout);
assertTrue(readBack.lazyLoading);
assertEquals(Boolean.FALSE, readBack.sheetEditOnClick);
assertTrue(readBack.pdfAnnotationMarkOnSelection);
assertEquals("reader.postMessage", readBack.hostMessageHandler);
Expand Down
1 change: 1 addition & 0 deletions python/src/bind_html.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ void odr_python::bind_html(py::module_ &m) {
.def_readwrite("viewport_width", &odr::HtmlConfig::viewport_width)
.def_readwrite("initial_zoom", &odr::HtmlConfig::initial_zoom)
.def_readwrite("lazy_layout", &odr::HtmlConfig::lazy_layout)
.def_readwrite("lazy_loading", &odr::HtmlConfig::lazy_loading)
.def_readwrite("min_content_margin", &odr::HtmlConfig::min_content_margin)
.def_readwrite("format_html", &odr::HtmlConfig::format_html)
.def_readwrite("html_indent", &odr::HtmlConfig::html_indent)
Expand Down
3 changes: 3 additions & 0 deletions python/tests/test_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,19 +77,22 @@ def test_html_config_viewport_defaults():
assert config.viewport_width is None
assert config.initial_zoom is None
assert config.lazy_layout is True
assert config.lazy_loading is False

config.viewport_mode = pyodr.HtmlViewportMode.fit_width
config.spreadsheet_viewport_mode = pyodr.HtmlViewportMode.actual_size
config.viewport_content = "width=420"
config.viewport_width = 420
config.initial_zoom = 1.5
config.lazy_layout = False
config.lazy_loading = True
assert config.viewport_mode == pyodr.HtmlViewportMode.fit_width
assert config.spreadsheet_viewport_mode == pyodr.HtmlViewportMode.actual_size
assert config.viewport_content == "width=420"
assert config.viewport_width == 420
assert config.initial_zoom == 1.5
assert config.lazy_layout is False
assert config.lazy_loading is True


def test_viewport_mode_reaches_the_html(odt_path):
Expand Down
6 changes: 6 additions & 0 deletions src/odr/html.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,12 @@ struct HtmlConfig {
/// browser lays out all of them, which a large file may not survive, but its
/// own find and a screen reader reach every one.
bool lazy_layout{true};
/// Write the chunks of a text file after the first as scripts of their own,
/// which the page loads once the reader comes near. `bring_offline` writes
/// them beside the page, and the HTTP server serves them. Needs
/// @ref lazy_layout. An editable render, and a file the page cannot spell in
/// UTF-8, write every line into the page.
bool lazy_loading{false};

/// The least distance the generated content keeps from the view's border. A
/// set side raises the inset the view already has, never lowers it; a unit
Expand Down
27 changes: 14 additions & 13 deletions src/odr/internal/html/frontend.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -225,19 +225,6 @@ void write_error_codes(const WritingState &state) {
state.out().write_script_end();
}

/// A json string that cannot end the `<script>` it sits in.
std::string script_string(const std::string &value) {
std::string result;
for (const char c : nlohmann::json(value).dump()) {
if (c == '<') {
result += "\\u003c";
} else {
result += c;
}
}
return result;
}

void write_script(const Asset &asset, const WritingState &state) {
if (const HtmlResourceLocation location =
locate(asset, state.config(), state.resources());
Expand Down Expand Up @@ -315,6 +302,20 @@ void html::write_search_style(const WritingState &state) {
write_style(search_css_asset, state);
}

std::string html::script_string(const std::string &value) {
std::string result;
// a byte that is no UTF-8 shows as U+FFFD, as the browser shows it in a page
for (const char c : nlohmann::json(value).dump(
-1, ' ', false, nlohmann::json::error_handler_t::replace)) {
if (c == '<') {
result += "\\u003c";
} else {
result += c;
}
}
return result;
}

std::string html::chunk_classes(const HtmlConfig &config, const bool near) {
if (!config.lazy_layout) {
return {};
Expand Down
2 changes: 2 additions & 0 deletions src/odr/internal/html/frontend.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ void write_chunk_style(const WritingState &state);
/// @p near where it is near the viewport before the script runs. Empty where
/// `HtmlConfig::lazy_layout` is off.
[[nodiscard]] std::string chunk_classes(const HtmlConfig &config, bool near);
/// A json string that cannot end the `<script>` it sits in.
[[nodiscard]] std::string script_string(const std::string &value);

/// For a view writing its style inline: whether to write a dark one, and the
/// `media` gating it. A print stays light, so the query names `screen`.
Expand Down
3 changes: 3 additions & 0 deletions src/odr/internal/html/frontend/text.css
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ body{margin:0;background:#fff}
/* A chunk far from the viewport keeps the height it had when it was last
shown. */
.odr-text-lines{display:flex;flex-direction:column;contain-intrinsic-height:auto calc(var(--odr-text-lines,256) * 20px)}
/* A chunk that has not loaded its lines yet holds the height they will take,
so the page does not move when they come. */
.odr-text-lines[data-odr-chunk-src]{min-height:calc(var(--odr-text-lines,256) * 20px)}
.odr-text-lines>div{position:relative;padding-left:calc(var(--odr-text-gutter-width) + 17px);counter-increment:odr-line}
.odr-text-lines>div:hover{background:var(--odr-text-wash)}
/* The numbers are ours, not the file's: generated content stays out of a
Expand Down
Loading
Loading