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
66 changes: 66 additions & 0 deletions app/src/main/java/com/markreader/format/DocumentFormat.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package com.markreader.format

import androidx.annotation.StringRes
import com.markreader.file.DocumentType

/**
* One document format: how to turn its text into something drawable, and what the chrome
* around it may offer.
*
* **Formats do not recognise files.** [com.markreader.file.DocumentTypeResolver] is the single
* detection authority and [FormatRegistry] is a [DocumentType] to [DocumentFormat] lookup over
* it. The reasoning is written up in `docs/plan/architecture.md` seam 2 and is not re-litigated
* here; the short version is that `DocumentResourceHandler` in the WebView host has to ask "is
* this file text", and moving detection into formats would drag Markwon and Prism4j into that
* layer to answer it.
*
* There is also no `type` property. Nothing asks a format what it reads — the registry's `when`
* is that mapping, and having the answer in two places is how the two drift apart.
*/
interface DocumentFormat {
/** What the viewer and editor may offer for documents of this format. */
val capabilities: FormatCapabilities

/**
* The format's own name, for chrome that has to say what a document is rather than which
* view of it is showing. Only read where [FormatCapabilities.hasRawView] is false.
*/
@get:StringRes
val nameRes: Int

/** What the editor shows in an empty document of this format. */
@get:StringRes
val editorPlaceholderRes: Int

/** The MIME type to ask the system picker for when saving a new document of this format. */
val creationMimeType: String

/**
* What to tell the user when [load] returns null.
*
* Null for a format that cannot fail, which is one decision rather than two: a format whose
* rendering *is* its source has nothing to fall back to and so no failure to describe.
*/
@get:StringRes
val renderFailureWarningRes: Int?

/**
* Render [text] for the given surface, or null if this format's renderer could not — in
* which case the caller shows the source and says so with [renderFailureWarningRes].
*/
suspend fun load(text: String, theme: RenderTheme): ReaderContent?

/**
* The document's table of contents. Empty unless [FormatCapabilities.hasHeadings].
*
* Takes both the source and its rendering because the offsets have to index the text the
* reader actually scrolls, not the markup that produced it.
*/
fun headings(text: String, content: ReaderContent): List<HeadingItem> = emptyList()

/**
* Drop any cached renderer. The reader palette changed, so a renderer built for the old one
* would paint the previous theme's colours.
*/
fun invalidateRenderers() {}
}
28 changes: 28 additions & 0 deletions app/src/main/java/com/markreader/format/FormatCapabilities.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.markreader.format

/**
* What the chrome around a document may offer.
*
* These used to be ten separate `is this Markdown` / `is this source code` tests spread across
* the viewer and the editor, which is why adding a third format meant editing ten call sites.
* Each field here exists because one of those sites asked the question — a capability with no
* caller is a comment pretending to be code, so none are declared ahead of a use.
*
* The defaults describe the least capable document there is: text that renders as itself.
*/
data class FormatCapabilities(
/** The document has a heading structure, so a table of contents is meaningful. */
val hasHeadings: Boolean = false,
/**
* The rendered form differs from the source, so the raw/rendered toggle does something.
* Where it is false the chrome names the format instead of claiming a view mode, because
* "Rendered" is not a true thing to say about a document whose rendering *is* its source.
*/
val hasRawView: Boolean = false,
/** Read and edited in the code font rather than the reading font. */
val isMonospaced: Boolean = false,
/** The editor offers Edit/Preview tabs and can render a live preview. */
val hasEditorPreview: Boolean = false,
/** The editor offers the markup formatting toolbar. */
val hasFormattingToolbar: Boolean = false
)
51 changes: 51 additions & 0 deletions app/src/main/java/com/markreader/format/FormatRegistry.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package com.markreader.format

import android.content.Context
import com.markreader.file.DocumentType
import com.markreader.format.code.SourceCodeFormat
import com.markreader.format.markdown.MarkdownFormat
import com.markreader.format.plaintext.PlainTextFormat

/**
* The [DocumentType] to [DocumentFormat] lookup.
*
* `architecture.md` seam 4 sketched this as a plain list, ordered by precedence. Precedence was
* for *recognition* — walk the list, first format that claims the file wins — and formats no
* longer recognise anything, so a list would carry an ordering that means nothing. What protects
* us instead is exhaustiveness: [formatFor] is a `when` over a sealed interface, so adding a
* document type fails to compile until someone decides which format reads it. P5 is still one
* field and one branch.
*
* Not a singleton, and not injected. One instance per view model, holding the renderers for as
* long as the screen lives.
*/
class FormatRegistry(private val markdown: DocumentFormat) {

constructor(context: Context) : this(MarkdownFormat(context.applicationContext))

/**
* One format per language seen, so the highlighter cache survives a theme toggle. Bounded by
* the languages opened on one screen, which is one for all but the curious.
*/
private val sourceCode = mutableMapOf<String, DocumentFormat>()

/**
* The format that reads [type], or **null for [DocumentType.Binary]** — bytes that are not
* text have no format, and that is the one case the caller has to handle rather than render.
*/
fun formatFor(type: DocumentType): DocumentFormat? = when (type) {
DocumentType.Markdown -> markdown
is DocumentType.SourceCode -> sourceCode.getOrPut(type.language) {
SourceCodeFormat(type.language)
}
// Csv reads as what it also is until P5 brings a format of its own.
DocumentType.Csv, DocumentType.PlainText -> PlainTextFormat
DocumentType.Binary -> null
}

/** The reader palette changed, so every renderer built against the old one is stale. */
fun invalidateRenderers() {
markdown.invalidateRenderers()
sourceCode.values.forEach { it.invalidateRenderers() }
}
}
13 changes: 13 additions & 0 deletions app/src/main/java/com/markreader/format/HeadingItem.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.markreader.format

/**
* One entry in a document's table of contents.
*
* [offset] is a character offset into the *rendered* text, because that is what the reader
* scrolls. Only formats whose [FormatCapabilities.hasHeadings] is true produce these.
*/
data class HeadingItem(
val text: String,
val level: Int,
val offset: Int
)
36 changes: 36 additions & 0 deletions app/src/main/java/com/markreader/format/ReaderContent.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.markreader.format

import android.text.Spanned

/**
* What a [DocumentFormat] produces, and the only thing the reader surface knows how to draw.
*
* This is Fossify Documents' `StructuredDocumentContent` idea — one renderer, several loaders —
* and it is what lets a format that produces a table share a screen with one that produces
* prose. The set is closed on purpose: three shapes, and a fourth format has to fit one of them
* or argue for a fourth.
*
* P3 defines it; the reader surface still takes `text: Any` and P4 is where that dies. So today
* every producer returns [Spans] and the view models unwrap it at one place each — which is the
* point of defining it a phase early rather than changing `load()`'s signature later.
*
* Lives here rather than beside `ReaderContentView` so that the format layer never imports UI.
* It moves to `render/` if and when that package is carved out with the dependency running that
* way.
*/
sealed interface ReaderContent {
/**
* A rendered document as HTML, for the WebView host to load. Produced from P6, when the
* shared renderer replaces Markwon.
*/
data class Html(val html: String) : ReaderContent

/**
* A rendered document as styled text. The Markwon and Prism paths today; after P6 it
* survives for raw mode, which needs no renderer at all.
*/
data class Spans(val spanned: Spanned) : ReaderContent

/** A tabular document, drawn by `TableRenderer`. Produced from P5, by the CSV format. */
data class Table(val rows: List<List<String>>) : ReaderContent
}
12 changes: 12 additions & 0 deletions app/src/main/java/com/markreader/format/RenderTheme.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.markreader.format

/**
* What a format needs to know about the surface it is rendering onto.
*
* One field, and deliberately a type rather than a bare `Boolean`: this is the parameter that
* renderer settings arrive through, it is half of the render cache key, and the option-3 host
* already has the grown-up version of the same idea in
* [com.markreader.webview.RendererSettings]. A boolean threaded through five signatures would
* have to be widened at every one of them.
*/
data class RenderTheme(val isDark: Boolean)
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.markreader.ui.markdown
package com.markreader.format.code

import android.graphics.Typeface
import android.text.SpannableStringBuilder
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.markreader.ui.markdown;
package com.markreader.format.code;

import io.noties.prism4j.annotations.PrismBundle;

Expand Down
59 changes: 59 additions & 0 deletions app/src/main/java/com/markreader/format/code/SourceCodeFormat.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.markreader.format.code

import com.markreader.R
import com.markreader.format.DocumentFormat
import com.markreader.format.FormatCapabilities
import com.markreader.format.ReaderContent
import com.markreader.format.RenderTheme
import kotlinx.coroutines.CancellationException

/**
* Source code, syntax-highlighted by Prism4j.
*
* One instance per language, because the language is a property of the document and not of the
* call — a format that took it as a `load()` argument would be asking every caller to remember
* something the registry already knows.
*
* There is no raw view: the highlighted form is the source, coloured. That is also why the
* viewer names the format in its chrome instead of claiming to show a "rendered" document.
*/
class SourceCodeFormat(private val language: String) : DocumentFormat {

/** Cheap to build, unlike Markwon — but the reader toggles themes often enough to keep. */
private val renderers = mutableMapOf<Boolean, SourceCodeRenderer>()

override val capabilities = FormatCapabilities(
hasHeadings = false,
hasRawView = false,
isMonospaced = true,
hasEditorPreview = false,
hasFormattingToolbar = false
)

override val nameRes = R.string.viewer_subtitle_source_code

override val editorPlaceholderRes = R.string.editor_placeholder_text

/**
* `text/plain`, not a per-language MIME type. The picker uses this to name a *new* file, and
* offering `text/x-kotlin` there would have the system suggest an extension for a language
* the user has not chosen yet.
*/
override val creationMimeType = "text/plain"

override val renderFailureWarningRes = R.string.viewer_warning_source_code

override suspend fun load(text: String, theme: RenderTheme): ReaderContent? = try {
ReaderContent.Spans(renderer(theme.isDark).highlight(text, language))
} catch (ex: CancellationException) {
throw ex
} catch (_: Exception) {
// A grammar Prism4j mishandles, or a pathological file. Either way: show the source.
null
}

override fun invalidateRenderers() = renderers.clear()

private fun renderer(isDark: Boolean): SourceCodeRenderer =
renderers.getOrPut(isDark) { SourceCodeRenderer(isDark) }
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.markreader.ui.markdown
package com.markreader.format.code

import android.text.SpannableStringBuilder
import android.text.Spanned
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.markreader.ui.markdown
package com.markreader.format.markdown

import android.graphics.Canvas
import android.graphics.Paint
Expand Down
Loading