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
65 changes: 65 additions & 0 deletions app/src/main/assets/renderer/android-host.html
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,71 @@
<link rel="stylesheet" href="css/document-overrides.css">

<style>
/*
* The bundled reading fonts.
*
* Host-only, and that is why they are declared here rather than in a stylesheet: the
* fonts ship in this APK's `res/font/`, and the desktop app bundles none of them. A
* vendored stylesheet naming these files would be a document stylesheet asserting
* something only one host can serve.
*
* The URLs are absolute because the prefix is a `WebViewAssetLoader` path handler
* (`FontResourceHandler.kt`), not a file beside this page — a relative href would
* resolve into `assets/renderer/`. Same origin, so `font-src 'self'` covers them.
*
* These names are what `ReaderRendererSettings.kt` puts at the head of each stack. If
* either side is edited alone the document renders in the fallback font and nothing
* reports an error, which is exactly the failure this whole block exists to fix — so
* `BundledFontsTest` asserts the two agree.
*
* `swap` rather than the default `block`: these are local reads that should complete
* within a frame, and if one somehow 404s a reader would rather have Georgia
* immediately than three seconds of invisible text and then Georgia.
*
* No italic face is bundled for any of them, so emphasis is synthesised by the WebView.
* That is what the TextView reader has always drawn; adding four more files to remove a
* skew is a decision about APK size, not about this plumbing.
*/
@font-face {
font-family: "Merriweather";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/__mdr_fonts/merriweather_regular.ttf") format("truetype");
}

@font-face {
font-family: "Merriweather";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("/__mdr_fonts/merriweather_bold.ttf") format("truetype");
}

@font-face {
font-family: "Merriweather Sans";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/__mdr_fonts/merriweather_sans_regular.ttf") format("truetype");
}

@font-face {
font-family: "Merriweather Sans";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("/__mdr_fonts/merriweather_sans_bold.ttf") format("truetype");
}

@font-face {
font-family: "JetBrains Mono";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/__mdr_fonts/jetbrains_mono_regular.ttf") format("truetype");
}

/*
* What `app.css` would otherwise give the document container, less everything that is
* about desktop chrome. `markdown.css` already owns the inline padding and
Expand Down
45 changes: 39 additions & 6 deletions app/src/main/assets/renderer/android-host.js
Original file line number Diff line number Diff line change
Expand Up @@ -176,19 +176,43 @@ view.on('activeHeading', slug => bridge.activeHeading(slug ?? ''));
view.on('find', state => bridge.findState(state.index, state.total));

/**
* Writes the three typography custom properties `markdown.css` reads.
* The code font, held here rather than in `config.js`.
*
* The app has a code-font setting and the shared configuration has no key for one — code takes
* `--vscode-editor-font-family` from the theme shim, which is a *palette* value. Adding a key
* to `config.ts` is desktop work, and desktop has no bundled monospace to point it at, so this
* host carries the setting alone: `parseConfig` would drop it, which is why it is read off the
* raw payload in `applyConfig` and kept here instead of going through `setConfig`.
*
* Undefined until the app sends one, which leaves the theme's own stack in place.
*
* @type {string | undefined}
*/
let codeFontFamily;

/**
* Writes the three typography custom properties `markdown.css` reads, and the one it reads for
* code.
*
* Nothing in the renderer does this today — `fontSize`, `lineHeight` and `fontFamily` are in
* `config.ts` and are read by no one, so `markdown.css` falls back to its own `14px` — which is
* a desktop-sized default on a phone. Delete this and its call sites the moment the shared
* renderer applies its own configuration.
*
* The code family goes on the **body**, not on `<html>` with the other three. `theme.css`
* declares `--vscode-editor-font-family` on `body.vscode-dark` and `body.vscode-light`, and a
* custom property declared on a nearer ancestor wins — so a value set on `<html>` would be
* shadowed by the theme and silently do nothing. An inline style on the body beats a class rule.
*/
function applyTypography() {
const config = getConfig();
const style = document.documentElement.style;
style.setProperty('--markdown-font-size', `${config.fontSize}px`);
style.setProperty('--markdown-line-height', `${config.lineHeight}px`);
style.setProperty('--markdown-font-family', config.fontFamily);
if (codeFontFamily) {
document.body.style.setProperty('--vscode-editor-font-family', codeFontFamily);
}
}

/** Settings the markdown-it engine is constructed with, so a change has to rebuild it. */
Expand Down Expand Up @@ -229,14 +253,23 @@ const page = {
},

/**
* @param json {string} a JSON settings object, in the shape `config.ts` persists. Validated
* by `parseConfig`, which drops unknown keys and falls back per-setting — it is the same
* check the desktop app applies to settings that have been on disk, and Kotlin's DataStore
* is no more trustworthy than a file someone hand-edited.
* @param json {string} a JSON settings object, in the shape `config.ts` persists, plus this
* host's own `codeFontFamily`. The shared keys are validated by `parseConfig`, which drops
* unknown ones and falls back per-setting — the same check the desktop app applies to
* settings that have been on disk, and Kotlin's DataStore is no more trustworthy than a file
* someone hand-edited. `codeFontFamily` is one `parseConfig` would drop, so it is taken off
* the raw payload and checked here.
*/
applyConfig(json) {
const raw = JSON.parse(json);
const before = getConfig();
const after = setConfig(parseConfig(JSON.parse(json)));
const after = setConfig(parseConfig(raw));

// Optional chaining because `parseConfig` tolerates a payload that is not an object at
// all, and a `null` from `JSON.parse` would otherwise throw here rather than there.
if (typeof raw?.codeFontFamily === 'string' && raw.codeFontFamily.trim().length > 0) {
codeFontFamily = raw.codeFontFamily;
}

applyTypography();
view.applyTheme();
Expand Down
121 changes: 121 additions & 0 deletions app/src/main/java/com/markreader/webview/FontResourceHandler.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package com.markreader.webview

import android.content.res.Resources
import android.webkit.WebResourceResponse
import androidx.webkit.WebViewAssetLoader
import com.markreader.R
import java.io.ByteArrayInputStream

/**
* Serves the app's bundled reading fonts to the page.
*
* The fonts live in `res/font/`, which is where the platform wants them: Compose's typography
* and the raw-text reader both reach them through `R.font`, and a `TextView` cannot load a
* typeface out of `assets/` without being handed one. But the page can only fetch what a
* [WebViewAssetLoader] handler serves, so without this the reading-font setting had no effect on
* rendered Markdown at all — the CSS named `Merriweather`, nothing supplied it, and the stack
* fell straight through to `Georgia`. Merriweather and "System serif" were the same font.
*
* ### Why a handler of our own
*
* Two routes suggested themselves and neither is this one.
*
* [WebViewAssetLoader.ResourcesPathHandler] over `res/` is the obvious answer, and it resolves
* names with `Resources.getIdentifier` — a lookup by string, in a release build that has
* `shrinkResources` on. That build is not hypothetical: in the release APK these files are
* `res/8g.ttf` and `res/X5.ttf`, so a name is already the *only* way to address them and it is
* the thing a shrinker is entitled to take. It also opens the door to every resource the app has,
* by name, for the sake of five files. A fixed table of ids costs nothing and is the whole door.
*
* Moving the TTFs to `assets/` would need no handler at all, since `AssetsPathHandler` is already
* registered — but it drags the Kotlin side along: [com.markreader.ui.theme.Typography] builds
* its families at module scope with no `Context` to get an `AssetManager` from, so the theme
* would have to become context-aware to fix a font in the WebView. The alternative, keeping both
* copies, ships every glyph twice in an APK that F-Droid rebuilds byte-for-byte.
*
* So: `R.font` ids, named as constants the compiler checks, which is also what keeps the shrinker
* from touching them.
*
* ### The other half is in the page
*
* Serving bytes is not enough — CSS has to declare the families. Those `@font-face` rules are in
* `android-host.html`, not in any vendored stylesheet, because **which fonts a host bundles is
* host business**: desktop ships none of these and would have to serve them from somewhere else
* if it did. [BUNDLED_FONTS] and that block are one fact in two files, and
* `BundledFontsTest` is what stops them drifting apart — a missing rule or a mistyped URL
* does not fail here, it silently renders in the fallback font, which is the bug this fixes.
*/
internal class FontResourceHandler(
private val resources: Resources,
private val onMiss: (String) -> Unit = {},
) : WebViewAssetLoader.PathHandler {

override fun handle(path: String): WebResourceResponse {
val id = BUNDLED_FONTS[path.trimStart('/')]
if (id == null) {
onMiss(path)
return notFound()
}

// A resource that is present at compile time and missing at run time means the shrinker
// took it, which is a build problem rather than a device one. It still cannot be allowed
// to reach the WebView's thread as an exception.
val body = runCatching { resources.openRawResource(id) }.getOrNull()
if (body == null) {
onMiss("$path — no such resource")
return notFound()
}

// No cache headers, matching `AssetsPathHandler`, which serves the renderer's own modules
// and stylesheets bare. These bytes are immutable within a build but the URL does not
// change between builds, so a long `max-age` would be a font that survives an app update.
return WebResourceResponse(MIME_TYPE, null, HTTP_OK, "OK", HEADERS, body)
}

private fun notFound(): WebResourceResponse =
WebResourceResponse(
"text/plain", "utf-8", HTTP_NOT_FOUND, "Not Found",
HEADERS, ByteArrayInputStream(ByteArray(0)),
)

internal companion object {

/**
* The served set, as URL name -> resource. Nothing else is reachable.
*
* Names match the resource names so a grep for either finds both. Only the weights the
* app actually bundles are here: there is no italic of any of them, so emphasis is
* synthesised by the WebView — the same skew the `TextView` reader has always drawn, and
* not a regression.
*/
val BUNDLED_FONTS = mapOf(
"merriweather_regular.ttf" to R.font.merriweather_regular,
"merriweather_bold.ttf" to R.font.merriweather_bold,
"merriweather_sans_regular.ttf" to R.font.merriweather_sans_regular,
"merriweather_sans_bold.ttf" to R.font.merriweather_sans_bold,
"jetbrains_mono_regular.ttf" to R.font.jetbrains_mono_regular,
)

private const val MIME_TYPE = "font/ttf"

const val HTTP_OK = 200
const val HTTP_NOT_FOUND = 404

/**
* `nosniff` for the same reason [DocumentResourceHandler] states it: the type here is
* asserted, and sniffing is how an asserted type becomes a different one.
*/
val HEADERS = mapOf("X-Content-Type-Options" to "nosniff")
}
}

/**
* The [WebViewAssetLoader] prefix the bundled fonts are served under.
*
* Deliberately not inside [RESOURCE_PREFIX]. Every path there names something the user adopted
* through SAF, and an app resource is not that — sharing the prefix would mean
* [SafPathSpace] having a branch for paths that are not virtual paths at all.
*
* Shared with the `@font-face` rules in `android-host.html`, which name it as an absolute path.
*/
internal const val FONT_PREFIX = "/__mdr_fonts/"
34 changes: 21 additions & 13 deletions app/src/main/java/com/markreader/webview/ReaderRendererSettings.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,17 @@ import kotlin.math.roundToInt
/**
* The reader's stored preferences, as the settings the renderer understands.
*
* Two of the app's settings do not survive the trip, and both are deliberate rather than
* overlooked:
* One of the app's settings still does not survive the trip, and it is deliberate rather than
* overlooked: **Sepia and AMOLED have no palette in the renderer.** It ships VS Code's two,
* `vscode-light` and `vscode-dark`, and [RendererTheme] says so. Sepia reads as light and AMOLED
* as dark until a third and fourth palette are written — and they belong in the *desktop* repo's
* `document-overrides.css`, since this app only vendors a copy of it.
*
* - **Sepia and AMOLED have no palette in the renderer.** It ships VS Code's two, `vscode-light`
* and `vscode-dark`, and [RendererTheme] says so. Sepia reads as light and AMOLED as dark
* until a third and fourth palette are written — and they belong in the *desktop* repo's
* `document-overrides.css`, since this app only vendors a copy of it.
* - **The bundled reading fonts are in `res/font/`**, which the page cannot reach: everything it
* loads comes through the asset loader. The families below still name Merriweather and
* JetBrains Mono first, so the mapping is already correct on the day the files are served —
* today CSS falls straight through them to the generic that follows.
* The fonts do arrive now. Both stacks below name a bundled family first, [FontResourceHandler]
* serves it out of `res/font/`, and `android-host.html` declares the `@font-face` rules that join
* the two — so Merriweather and "System serif" are finally different fonts. **Each stack still
* ends in a generic**, and that is not vestigial: the page falls back per family, so a font whose
* fetch fails degrades to something a phone actually has rather than to the WebView's default.
*/
fun rendererSettingsFor(
preferences: UserPreferences,
Expand All @@ -35,12 +35,14 @@ fun rendererSettingsFor(
fontSize = fontSize,
lineHeight = lineHeight.coerceIn(MIN_LINE_HEIGHT, MAX_LINE_HEIGHT),
fontFamily = readingStack(preferences.readingFont),
codeFontFamily = codeStack(preferences.codeFont),
)
}

/**
* The body stack. Code is not asked about here: a fenced block takes its font from
* `--vscode-editor-font-family` in the vendored theme, not from the document's body font.
* The body stack. It does not reach fenced code, which takes its font from
* `--vscode-editor-font-family` in the vendored theme — hence [codeStack] as a second setting
* rather than one family for the whole document.
*/
private fun readingStack(font: ReadingFontPreference): String = when (font) {
ReadingFontPreference.Merriweather -> "Merriweather, Georgia, \"Noto Serif\", serif"
Expand All @@ -49,7 +51,13 @@ private fun readingStack(font: ReadingFontPreference): String = when (font) {
"\"Merriweather Sans\", system-ui, \"Roboto\", \"Noto Sans\", sans-serif"
}

/** Unused until the fonts are served, and kept beside its sibling so the pair move together. */
/**
* The stack for fenced code, which the page applies over the theme's own monospace value.
*
* `SystemMono` deliberately keeps `"Roboto Mono"` ahead of the generic even though it is the
* *system* choice: `monospace` on Android resolves to Droid Sans Mono on older builds, and the
* point of the setting is "not the bundled font", not "the oldest font on the device".
*/
internal fun codeStack(font: CodeFontPreference): String = when (font) {
CodeFontPreference.JetBrainsMono -> "\"JetBrains Mono\", \"Roboto Mono\", monospace"
CodeFontPreference.SystemMono -> "\"Roboto Mono\", monospace"
Expand Down
18 changes: 15 additions & 3 deletions app/src/main/java/com/markreader/webview/RendererSettings.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ enum class RendererTheme(internal val wire: String) {
* The renderer settings the Android host drives.
*
* A deliberately small slice of the shared renderer's configuration — the fields the app has an
* opinion about today. The wire format is the object `config.ts` persists, and the page validates
* every value again through `parseConfig` on the way in: an out-of-range font size falls back to
* the host default for that one setting rather than rendering something unreadable.
* opinion about today, plus [codeFontFamily], which is this host's alone. The wire format is the
* object `config.ts` persists, and the page validates every value again through `parseConfig` on
* the way in: an out-of-range font size falls back to the host default for that one setting
* rather than rendering something unreadable.
*
* A null means "leave the host default alone". `android-host.js` sets phone-appropriate
* typography through `setHostDefaults`, and an absent key is not overwritten by `parseConfig` —
Expand All @@ -29,12 +30,23 @@ data class RendererSettings(
val fontSize: Int? = null,
val lineHeight: Int? = null,
val fontFamily: String? = null,
/**
* The monospace stack for fenced code.
*
* The one field here that is **not** part of the shared configuration. Code takes its font
* from `--vscode-editor-font-family`, which the vendored theme shim declares as a palette
* value, so there is no `config.ts` key to fill — `parseConfig` drops this one and
* `android-host.js` reads it off the raw payload instead. It is here rather than in a second
* call because it is a reader setting like the other four, and it changes with them.
*/
val codeFontFamily: String? = null,
) {
internal fun toJson(): JSONObject = JSONObject().apply {
put("theme", theme.wire)
put("wordWrap", wordWrap)
fontSize?.let { put("fontSize", it) }
lineHeight?.let { put("lineHeight", it) }
fontFamily?.let { put("fontFamily", it) }
codeFontFamily?.let { put("codeFontFamily", it) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,11 @@ class WebViewDocumentHost(
.addPathHandler(RESOURCE_PREFIX, DocumentResourceHandler(space) { miss ->
Log.i(TAG, "no document resource at $miss")
})
// The bundled reading fonts. `res/font/` is unreachable from the page otherwise, which
// is why the reading-font setting used to stop at the WebView's edge.
.addPathHandler(FONT_PREFIX, FontResourceHandler(context.resources) { miss ->
Log.w(TAG, "no bundled font at $miss")
})
.build()

/**
Expand Down
Loading