Compare commits

..
Author SHA1 Message Date
Julia 970b2c6ca1 chore: release 1.4.1 (#2447)
Compile Release / build-release (push) Canceled after 0s
## Improvements

* Moved the File Manager breadcrumb into the Contents card header for a
cleaner, more consistent interface.
* Updated the Wireless Transfer section of the User Guide with clearer
instructions.
* Battery status bar indicator no longer changes position when adding or
removing bookmarks.

## Performance

* Optimized path normalization for faster file handling.
* Significantly improved bookmark rendering by removing unnecessary
XPath lookups.
* Optimized dithered rectangle drawing (fillRectDither) using a
byte-aligned rendering implementation, improving display performance on
supported devices.

## Bug Fixes

* Fixed an issue where the Inverted Orientation label was incorrectly
combined with the Color Filter label for Geman localization.
* Fixed excessive ghosting on the X3 cover screen during sleep
2026-06-26 17:40:04 -04:00
Julia Nguyen b6ce599b20 fix: address release review feedback 2026-06-26 16:22:20 -04:00
Juliaandcoderabbitai[bot] f54eab2725 fix: typo in STR_ORIENTATION_INVERTED Spanish translation
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-06-26 16:13:19 -04:00
Justin Mitchell 0d4c9ab91b Bump version to 1.4.1 2026-06-26 15:48:19 -04:00
Julia 0daa9db243 fix: keep status bar indicators stable when toggling bookmarks (#2444) 2026-06-26 19:26:33 +03:00
Uri TauberandRyan Mercado a2f2eea79e perf: Optimize fillRectDither with Byte-Aligned fillRectImpl (#2270)
## Summary

* **What is the goal of this PR?**
Replace the pixel-by-pixel `fillRectDither` implementation with a new
byte-aligned `fillRectImpl` that eliminates per-pixel
`rotateCoordinates` calls and Read-Modify-Write bitwise loops, yielding
a significant rendering speedup on ESP32 E-ink framebuffers.

Ported from @rhythmerc's crosspoint-reader fork commit 27ea625.

* **What changes are included?**

- **`GfxRenderer.cpp` — `fillRectDither` refactor:** The existing
`if/else if` chain is replaced with a `switch` statement that delegates
each `Color` case to the new `fillRectImpl<Color>()` template,
eliminating runtime branching.

- **`GfxRenderer.cpp` — new `fillRectImpl<C>()` template:** Core of the
optimization. Key behaviors:
    - Clips the rectangle in logical space upfront.
- Rotates only **2 opposing corner points** (top-left and bottom-right)
into physical framebuffer space instead of rotating every pixel
individually.
- Derives physical-space `byteStart`/`byteEnd` and precomputes
`headMask` / `tailMask` for MSB-first partial-byte boundaries,
performing RMW only on the edge bytes.
- **Solid fills (`Black` / `White`):** Uses `memset` for all interior
full-byte runs per row — no per-pixel writes.
- **Dithered fills (`LightGray` / `DarkGray`):** Precomputes both parity
variants of `blackMask` (even/odd `py`) **outside** the row loop,
eliminating the previously re-evaluated 8-bit construction loop on every
physical row. Interior full bytes are then written with a single
`memset(whiteMask)`.
- Uses `if constexpr` throughout to dispatch on `Color` at compile time,
generating zero runtime branches per template instantiation.

- **`GfxRenderer.h`:** Declares the new private `fillRectImpl<Color>()`
template method with an explanatory doc-comment.

- **Explicit template instantiations** added for all four active `Color`
variants (`Black`, `White`, `LightGray`, `DarkGray`).

## Additional Context

* **Performance:** The primary motivation is ESP32 E-ink framebuffer
performance. The old path called `rotateCoordinates` and did a full RMW
for every single pixel in the rectangle. The new path calls
`rotateCoordinates` exactly **twice** per fill regardless of rectangle
size, then operates at byte granularity — a complexity reduction from
O(W×H) coordinate transforms to O(1).
* **Dither correctness:** The `blackMask` precomputation relies on the
dither pattern having period 2 in both logical X and Y, which makes the
per-row byte pattern repeat with period 2 in `py`. Reviewers should
verify the `lxBase`/`lyBase` derivations for all four orientations
(`Portrait`, `PortraitInverted`, `LandscapeClockwise`,
`LandscapeCounterClockwise`) match the inverse of `rotateCoordinates`.
* **Edge case — single-byte rows:** When `byteStart == byteEnd`, the
head and tail masks are ANDed together into a single `rectMask` to avoid
double-masking the same byte. This path should be tested with narrow
rectangles (width < 8px).
* **No behavioral change for `Color::Clear`:** The `Clear` case exits
early via `if constexpr` and is a no-op, matching the original behavior.

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**< YES >**_

---------

Co-authored-by: Ryan Mercado <rmercado@firstdollar.com>
2026-06-26 12:16:25 -04:00
Ankit 86a9b9c4a2 docs: update user guide wireless transfer section (#2369)
Co-authored-by: Uri Tauber <uritaube@gmail.com>
Closes #1407
2026-06-26 08:25:52 +03:00
Julia Nguyen ba44978ac4 Merge branch 'master' into develop 2026-06-25 17:51:56 -04:00
Pietro Campagnano 555f76da88 feat: move file manager breadcrumb into contents card header (#2430) 2026-06-26 00:45:43 +03:00
Justin Mitchell 0a57c0a5a7 Fix X3 display ghosting on cover screen transitions
Force display resync on X3 when HALF refresh is requested to clear prior content before rendering. Add grayscale preconditioning for X3's UC81xx controller to even out single-pixel dithering artifacts that appear as speckle with its turbo BW waveform.
2026-06-25 17:42:19 -04:00
Uri Tauber a09aef0889 fix: Optimize Bookmark Rendering by Removing XPath Lookup (#2417)
## Summary

fix #2414 

### Root Cause

`updateBookmarkFlag()` was introduced in commit 1db1442 and is executed
on every render (every page turn). The function calls:

`ProgressMapper::toSavedProgress()`
→ `ChapterXPathResolver::findXPathForProgress()`

This path decompresses the current EPUB section content twice:

1. To count visible characters.
2. To resolve the corresponding XPath.

For larger sections (e.g. ~133 KB decompressed content), this adds
approximately **1 second of I/O overhead per page turn**, with the cost
increasing as chapter size grows.

### Fix

`updateBookmarkFlag()` only needs to determine whether a bookmark falls
within the currently displayed page range.

The required information is already available during rendering:

* `currentPage`
* `section->pageCount`
* `currentSpineIndex`

Instead of converting the current location to a saved progress object
(and resolving an XPath), the implementation now computes the current
page's progress range directly and compares bookmark percentages against
that range.

This is effectively the same percentage-based matching logic already
used as a fallback in `bookmarkMatchesProgress()` when XPath matching is
unavailable.

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**< PARTIALLY >**_
2026-06-25 09:20:24 -04:00
Bastian 8626d69f46 fix: small translation changes for german (#2420) 2026-06-25 09:29:05 +03:00
Uri Tauber fc89e57e69 perf: optimise normalisePath (#2162) 2026-06-25 09:01:50 +03:00
Julia 487613b082 fix: split inverted orientation label from color filter label (#2421)
## Summary

* **What is the goal of this PR?** 
* Fix the “Inverted” translation so reader orientation and color/filter
inversion can use separate labels.
* **What changes are included?**
* Adds `STR_ORIENTATION_INVERTED` for the inverted portrait orientation
option.
* Updates the reader orientation setting to use
`STR_ORIENTATION_INVERTED` instead of reusing `STR_INVERTED`.
* Leaves `STR_INVERTED` for the sleep cover filter and tilt page-turn
mode
  * Adds the new orientation string across all 26 locale YAML files.

## Additional Context

* The original issue was found by a user in German, where `STR_INVERTED`
was translated as `Hochformat 180°`, which made sense for orientation
but not for color filters.
* This is a UI-label-only change. It does not change persisted
orientation values or settings behavior.
* Reviewer note: non-English wording may still benefit from
native-speaker review, especially for the new orientation-specific
labels and to verify the interchangeable usage between inverted color
and inverted tilt page turn direction.
---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**< YES >**_
2026-06-25 00:47:06 -04:00
JuliaandHusam Younis 8d5b119644 fix: sync master into develop (#2423)
Sync develop branch with master

Co-authored-by: Husam Younis <youhusam@gmail.com>
2026-06-24 22:07:13 -04:00
Husam Younis 7271c00d35 feat: Allow statusbar clock to be on the left (#2359) 2026-06-25 00:23:23 +03:00
Uri Tauber 6e8dbd7f23 chore: Update version to 1.4.0 (#2283)
Compile Release / build-release (push) Canceled after 0s
2026-06-24 18:30:32 +03:00
Uri Tauber c4b1d9644a fix: correct font size selection (#2410) 2026-06-24 17:01:10 +03:00
Justin Mitchell f6e59aab72 feat: Add displayGrayscaleBase method for differential refresh
(#2334)

Introduces a new display method that prepares the framebuffer as a base
frame for grayscale overlays. On X3 panels, this uses the OEM
differential base waveform (AA-pre-BW) without forcing a resync. Other
panels fall back to normal display with configurable refresh mode.


Dependent upon matching SDK commit to work
2026-06-24 07:53:19 -04:00
Uri Tauber 9ad2da0950 fix: add seven missing hebrew translations (#2409) 2026-06-24 09:34:40 +03:00
Uri TauberandJulia Nguyen 1db1442319 fix: several bookmarks UX improvments (#2372)
## Summary

This PR enhances the EPUB reader's bookmark system with two
complementary improvements: a per-page bookmark indicator icon and
toggle behavior on the existing long-press action.

---

### What Changed

**Bookmark Toggle (was: add-only)**

The long-press Confirm action now toggles bookmarks rather than always
adding. `addBookmark()` checks whether a bookmark with the same xpath
already exists in the in-memory cache:
- If found → removes it and shows "Bookmark removed."
- If not found → adds it and shows "Bookmark added."

A new `STR_BOOKMARK_REMOVED` translation string was added to support the
removal message.

**Bookmark Icon Indicator**

A `BookmarkIcon` is now drawn at the top-right corner of the page
whenever the current page has a bookmark. `updateBookmarkFlag()` is
called at render time to determine whether the current page is
bookmarked.

**In-Memory Bookmark Cache**

Bookmarks are now loaded into `cachedBookmarks` on `onEnter()` rather
than being re-read from disk on every toggle. All subsequent add/remove
operations work against this cache and flush to disk, avoiding redundant
file reads on each bookmark action.

**Faster bookmarks list**

Previously, calculating "page X/Y" for each entry required decompressing
the entire spine item. We now persist `si`/`pc`/`pp` (spine index, page
count, and page progress) in the bookmark JSON when saving, and restore
them when loading. This avoids the expensive `toCrossPoint()` loop in
`onEnter()`, significantly reducing the cost of initializing the
bookmarks list.

---

### Files Changed

- `EpubReaderActivity.cpp` — `addBookmark()` toggle logic,
`updateBookmarkFlag()` (new), icon rendering in `renderContents()`,
cache initialization in `onEnter()`
- `EpubReaderActivity.h` — new fields: `currentPageBookmarked`,
`bookmarkRemoved`, `cachedBookmarks`; new method declaration
`updateBookmarkFlag()`

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**< PARTIALLY >**_

---------

Co-authored-by: Julia Nguyen <julia@uxj.io>
2026-06-23 14:55:01 -04:00
Leopoldo Pla Sempere 362dcb2a65 feat: Smooth progressive JPEG cover upscales in BMP conversion (#2214) 2026-06-22 13:55:12 -04:00
Julia d1abcc00a2 feat: render grayscale epub images without text aa (#2393) 2026-06-22 17:15:27 +03:00
Zeph 0ce874f8d4 feat: footnote returns to original position on deep sleep to avoid losing reading position (#2394) 2026-06-22 15:28:42 +03:00
kygia 282514f755 fix(epub): flush displaced anchor before overwrite (#2336) (#2382)
## Summary

* **What is the goal of this PR?** (e.g., Implements the new feature for
file uploading.)

Fixes footnote links landing in start section instead of the specific
foot note target.

* **What changes are included?**

ChapterHtmlSlimParser.cpp: call flushPendingAnchor() before overwriting
pendingAnchorId. First id got lost due to consecutive non-block elements
carry ids, the first id was lost and the reader had no page to jump to,
so it defaulted to page 0.

## Additional Context

Tested with the epubs attached to to #2336. Need to clear .crosspoint/
cache after flashing so the anchor map gets rebuilt with the fix.

* Add any other information that might be helpful for the reviewer
(e.g., performance implications, potential risks,
  specific areas to focus on).

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? 

Partially assisted with writing documentation.
2026-06-21 16:43:39 -04:00
SurprisedDuck d1e4650e19 fix: don't justify-stretch a leading no-break space (#2185) (#2298) 2026-06-21 16:12:14 +03:00
Justin Mitchell 370f87ea01 feat: add Spacing Modifier Letters range to font presets (#2194) 2026-06-21 09:14:42 +03:00
Leopoldo Pla Sempere 8b9a73a735 chore: update Spanish, Catalan, and Valencian translations (#2384) 2026-06-21 00:26:30 +03:00
Julia 29c69b3d8f fix: automatically connect to wifi for clock sync (#2379)
## Summary

* **What is the goal of this PR?** 
* Make manual clock sync work even when the device is not already
connected to Wi-Fi, so the user can start the sync flow directly from
settings instead of being blocked by connection state.
* **What changes are included?**
* `ClockSyncActivity` now launches the normal Wi-Fi selection flow
before syncing when the device is offline.
* After Wi-Fi selection succeeds, clock sync resumes automatically and
performs the existing forced NTP sync.
* If clock sync had to bring Wi-Fi up, the activity disconnects and uses
the existing silent restart cleanup path to avoid leaving the device in
a fragmented post-Wi-Fi heap state.
* The completion UI now only advertises Back, matching the updated input
handling.

## Additional Context

* Existing behavior is unchanged when Wi-Fi is already connected: the
activity syncs immediately.
* Cancelling Wi-Fi selection exits the clock sync flow.

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**< YES >**_
2026-06-20 22:09:28 +03:00
Matteo Scopel 4b5a84dd81 chore: update the Italian translation (#2376) 2026-06-20 22:08:35 +03:00
muhasandmuhas b9874c8114 fix(lang): [russian] new string (#2374)
Co-authored-by: muhas <mail@muhas.name>
2026-06-19 13:15:15 +03:00
Paul Delestrac b1131795d8 feat: add live font preview pane to font selection screen (#2349)
## Summary
* Adds a live font preview pane to the font selection screen so users
can see how a font looks before committing to it.
* Changes
* A preview pane occupying the top 30% of the font selection screen,
rendering sample pangram text in the previewed font
* A two-step confirm flow: first press (enter button) previews the font,
second press selects it
* Back restores the original font settings, so browsing has no side
effects
* Layout dimensions cached in `onEnter()` to avoid redundant
recalculation between `loop()` and `render()`

## Additional Context
* Preview sample text is hardcoded English; didn't want to use AI for
translation as I would not be able to verify the output in most
languages...
* The preview pane reduces visible list height; this is compensated by
passing the reserved height into `getNumberOfItemsPerPage`

---
### AI Usage
Did you use AI tools to help write this code? **PARTIALLY**
AI use for assisting in coding and in writing the PR description.
2026-06-18 18:16:28 -04:00
Pedro CardosoandClaude Opus 4.8 5e990b3991 fix: prevent progress.bin corruption from interrupted writes (#2275) (#2305)
## Summary

* **What is the goal of this PR?**
Fixes #2275. A book could get stuck reopening on an old page, with
progress no
longer saving and neither "Delete Book Cache" nor "Clear Reading Cache"
able to
fix it. Root cause: `progress.bin` was written truncate-in-place, so an
interrupted write (power loss, or a crash mid-SPI during sleep) left it
with a
broken FAT cluster chain that the firmware could neither rewrite nor
delete —
  recovery required `fsck`/manual deletion on a host PC.

  Confirmed in the SDK: `SDCardManager::openFileForWrite` opens with
`O_RDWR | O_CREAT | O_TRUNC`, so the canonical file is zeroed before the
few
progress bytes are rewritten — exactly the window that corrupts the FAT
chain.

* **What changes are included?**
  * New shared helper `ProgressFile::writeAtomic()`
    (`src/activities/reader/ProgressFile.h`): writes progress to
    `progress.bin.tmp`, flushes and closes it, then `remove`s the old
`progress.bin` and `rename`s the temp into place. An interrupted write
now
only ever damages the throwaway temp; the canonical file is never torn.
  * All three readers route their progress saves through the helper:
EPUB (`EpubReaderUtils.h`), `TxtReaderActivity`, `XtcReaderActivity` —
they
    all shared the identical vulnerable pattern.
* Minor: `EpubReaderUtils::saveProgress` now takes `const Epub&` (clears
a
    cppcheck `constParameterReference` finding).

## Additional Context

* **Crash-safe, not metadata-atomic.** On FAT the replace is `remove` +
`rename`
(two directory ops; SdFat's `rename` won't overwrite, hence
remove-first). A
crash between them leaves *neither* file, which reads as "no saved
progress" on
next launch — a harmless reset to an old page, never a
corrupt/unclearable file.
  The guarantee is that `progress.bin` is never half-written.
* **Prevents, does not repair.** This stops new corruption on healthy
cards. It
cannot fix an already-corrupted `progress.bin` (removing it may itself
fail at
the FAT level) — those still need `fsck`/manual deletion, as in the
issue's
  workaround.
* **Known follow-up (out of scope here):** a crash *while writing the
temp* can
leave an orphan `progress.bin.tmp`. It's harmless and self-healing (the
next
save overwrites it, and it never blocks reading progress), but a
boot-time
  orphan-`.tmp` cleanup would be a tidy follow-up.
* **Focus areas for review:** the close-before-rename ordering in
  `ProgressFile.h` and the remove-before-rename rationale.

## Verification

* `./bin/clang-format-fix` — clean
* `pio check --fail-on-defect low --fail-on-defect medium
--fail-on-defect high` — no defects
* `pio run` — SUCCESS (RAM 30.9%, Flash 78.8%; footprint essentially
unchanged)
* Tested on a **Xteink X4** device: open book, turn pages, sleep/exit,
reopen —
  progress now restores to the navigated page across all three readers
  (EPUB / TXT / XTC).

---

### AI Usage

Did you use AI tools to help write this code? _**YES**_

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:01:56 -04:00
Uri Tauber ff1951c715 fix: correct behaviour for prev/next side buttons (#2373)
## Summary

* **What is the goal of this PR?** fix #2365
2026-06-18 13:13:35 -04:00
Ankit 121c0690b0 fix: use STR_SELECT instead of STR_OPEN in bookmark button hint (#2371) 2026-06-18 19:58:47 +03:00
16b0853654 fix(epub): NFC-normalize EPUB text so NFD diacritics render correctly (#2277)
Co-authored-by: Uri Tauber <uritaube@gmail.com>
Co-authored-by: Julia <julia@uxj.io>
2026-06-18 13:00:01 +03:00
Uri Tauber 7d639cf880 fix: submodule pointer (#2368) 2026-06-17 22:35:12 +03:00
Justin MitchellandUri Tauber 22f3575064 feat: Support for Korean line breaks and glyph spacing (#2288)
Co-authored-by: Uri Tauber <uritaube@gmail.com>
2026-06-17 17:27:03 +03:00
darkbubluandClaude Opus 4.7 d4069aeae5 feat: long-press Confirm launches KOReader sync from EPUB (#1808)
## Summary
- Hold the menu (Confirm) button for ≥1s while reading an EPUB to launch
the existing `KOReaderSyncActivity` directly — replaces the three-step
path (open reader menu → scroll to Sync → confirm) with a single
gesture.
- Reuses `ReaderUtils::GO_HOME_MS` (same 1s threshold used by long-press
Back) and the existing `KOREADER_STORE.hasCredentials()` guard.
- Adds a Controls picker **"Long-press Menu"** (`longPressMenuFunction`,
default **Bookmark**) that **cycles through the available functions**
bound to the long-press gesture: `KOSync → Disabled → Bookmark`. The
field name and `LONG_PRESS_MENU_FUNCTION` enum are intentionally general
so future actions (dictionary lookup, table of contents, etc.) can be
appended without another schema migration. The setting is **not** a
binary toggle.
- Existing menu Sync entry still works — both call sites share one
extracted helper (`launchKOReaderSync`); no logic duplication.
- Short-press Confirm release is gated on duration so the reader menu
does not also open after a long press that *acts*, mirroring the
existing long-press Back pattern.
- **No-credentials fall-through:** `launchKOReaderSync()` now returns
whether it acted. When the function is set to KOSync but no KOReader
credentials are stored, the long-press is a no-op that **falls through
to open the reader menu** — so the menu stays reachable instead of the
hold silently swallowing the gesture. The release is only suppressed
when sync actually launched or surfaced a save error.

## Test plan
- [x] `pio run` succeeds clean for the `default` ESP32-C3 environment.
- [x] On-device, value **KOSync**, valid KOReader credentials:
long-press Confirm ≥1s → sync screen launches; get + update progress
return HTTP 200; release does **not** also open the reader menu; returns
to the same page.
- [x] On-device, value **KOSync**, **no** credentials: long-press
Confirm falls through and **opens the reader menu** (regression fix);
short-press also opens the menu.
- [x] On-device: menu → Sync still launches the same screen (shared
helper) and syncs (200/200).
- [x] On-device, value **Bookmark**: long-press drops a bookmark and
does **not** also open the menu.
- [x] On-device, value **Disabled**: long-press Confirm opens the menu
on release; no sync, no bookmark.
- [x] On-device: long-press Back still goes to the file browser
(unchanged path).
- [x] Heap: epub is released before the TLS handshake (frees ~16 KB);
min free heap stayed ~84 KB during sync, well above the safe floor. No
panics/OOM across the session.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-17 17:10:26 +03:00
Mauvis LedfordandJulia 54d5a788a5 feat: add drag-and-drop to upload modal (#2290)
Co-authored-by: Julia <julia@uxj.io>
2026-06-17 16:53:31 +03:00
Ankit f66220a245 fix: enable Shift key for URL keyboard input (#2178) (#2357) 2026-06-17 16:32:36 +03:00
KymAndriyandKymAndriy 2266060e84 fix: Ukrainian translation (#2354)
Co-authored-by: KymAndriy <test@notamail.ua>
2026-06-16 14:02:23 +03:00
Julia 90039d7f4d fix(koreader): resolve element XPath progress against visible body text (#2308) 2026-06-15 22:03:32 -04:00
rafaelmsseandRafael Santos 16eb66d7cf fix: add missing HTML 4.01 named entities (#2352)
Co-authored-by: Rafael Santos <rmsantos@applaudostudios.com>
2026-06-15 22:25:37 +03:00
RoninandUri Tauber 02bab00be6 fix: swap reader menu navigation direction in CCW/inverted (#2321) (#2341)
Co-authored-by: Uri Tauber <uritaube@gmail.com>
2026-06-15 22:19:34 +03:00
Mauvis Ledford 55a56914d7 fix(i18n): localize home empty-state strings (#2342) 2026-06-14 21:40:05 +03:00
97 changed files with 2683 additions and 646 deletions
+43 -12
View File
@@ -122,19 +122,43 @@ A **Wi-Fi signal strength indicator** (dBm) is displayed on-screen during joined
CrossPoint supports sending books from Calibre using the CrossPoint Reader device plugin.
1. Install the plugin in Calibre:
- Head to https://github.com/crosspoint-reader/calibre-plugins/releases to download the latest version of the crosspoint_reader plugin.
- Download the zip file.
- Open Calibre → Preferences → Plugins → Load plugin from file → Select the zip file.
#### Installing the Plugin in Calibre
2. On the device: File Transfer -> Calibre Wireless, then join a network.
If you don't already have the plugin installed:
3. Make sure your computer is on the same Wi-Fi network.
1. Head to https://github.com/crosspoint-reader/calibre-plugins/releases to download the latest version of the crosspoint_reader plugin.
2. Download the zip file.
3. Open Calibre → Preferences → Plugins → Load plugin from file → Select the zip file.
4. Restart Calibre.
4. In Calibre, click "Send to device" to transfer books.
#### Configuring the CrossPoint Plugin in Calibre
1. In Calibre select Preferences.
2. In the Preferences dialog select Plugins.
3. In Plugins search for "crosspoint".
4. Click on "Customize plugin".
5. Update the value for "Host" to match the IP for your device.
6. Leave the other settings as they are.
7. [optional] Modify the "Upload path" to point to a subfolder other than the root "/" folder. Enter this as a path relative to the root folder. Example: `/mybooks`
8. Restart Calibre.
<img width="420" height="385" alt="Image" src="https://github.com/user-attachments/assets/01fc7e33-a9a7-48ba-9e26-2e68d1f9daec" />
#### Uploading Books
To upload a book using the CrossPoint plugin in Calibre:
1. On the device: File Transfer -> Calibre Wireless, then join a network.
2. Select one or more books.
3. Right-click on that selection.
4. Select "Send to Device" > "Send to main memory"
The CrossPoint plugin will connect to your device, create a folder for the book's author in the root folder (or the folder you configured for the plugin), then copy the book into that folder.
<img width="783" height="310" alt="Image" src="https://github.com/user-attachments/assets/741b0909-2e1d-4f16-8af0-2c43fbda5ce6" />
#### Removing a Book
Books cannot be removed from your device through Calibre. Use the web interface instead.
### 3.6 Settings
@@ -241,6 +265,10 @@ The Settings screen allows you to configure the device's behavior. There are a f
- "Chapter Skip" (default) - Long-pressing skips to next/previous chapter
- "Page Scroll" - Long-pressing scrolls a page up/down
- **Long-press Menu**: Selects the function bound to holding the menu button (Confirm) while reading an EPUB. **Cycles through the available functions** each time the setting is selected — additional functions may be added in future releases, so this is not a binary on/off toggle. A short press of Confirm always opens the reader menu as normal:
- "Bookmark" (default) - Hold Confirm (~0.4 second) to drop a bookmark at the current page.
- "KOSync" - Hold Confirm (~1 second) to launch KOReader sync directly.
- "Disabled" - Long-press is ignored; only short-press opens the reader menu.
- **Short Power Button Click**: Controls the effect of a short click of the power button:
@@ -523,11 +551,14 @@ On the **Xteink X3**, the gyroscope can be used to turn pages by tilting the dev
When reading an EPUB that contains footnotes, you can navigate to the footnote text by selecting the footnote reference in the book. From the footnote, you can return to your original reading position.
If the device goes to sleep or you close the book while viewing a footnote, the book reopens to your original reading position, not the footnote.
### System Navigation
* **Return to Home:** Press the **Back** button to close the book and return to the **[Home](#31-home-screen)** screen.
* **Return to Browse Files:** Press and hold the **Back** button to close the book and return to the **[Browse Files](#33-browse-files-screen)** screen.
* **Reader Menu:** Press **Confirm** to open the **[Reader Menu](#5-reader-menu)**, which includes chapter navigation, reading options, and more.
* **Long-press Confirm (configurable):** Holding **Confirm** runs the function chosen by the **Long-press Menu** setting in **[Controls Settings](#363-controls)** — "Bookmark" (default) drops a bookmark, "KOSync" launches KOReader Sync, "Disabled" does nothing. A short press always opens the Reader Menu.
### Supported Languages
@@ -574,9 +605,9 @@ Accessible by selecting **Chapters** from the Reader Menu.
Bookmarks can be created to quickly save and restore your place in a book.
To create a bookmark, hold **Confirm** for 1 second while inside a book. A popup will appear letting you know a bookmark was created. The popup message will automatically disappear in a couple of seconds.
To create a bookmark, hold **Confirm** for about half a second while inside a book. A popup will appear letting you know a bookmark was created. The popup message will automatically disappear in a couple of seconds.
To open bookmarks, press **Confirm** while inside a book. Then navigate to the **Bookmarks** menu. Bookmarks can be opened by navigating to them and pressing **Confirm**, which will redirect you to that place in the book. You can delete bookmarks by holding **Confirm** for 1 second, and then pressing **Confirm** again to confirm deletion, or **Back** to cancel.
To open bookmarks, press **Confirm** while inside a book. Then navigate to the **Bookmarks** menu. Bookmarks can be opened by navigating to them and pressing **Confirm**, which will redirect you to that place in the book. You can delete bookmarks by holding **Confirm** for about 0.7 seconds, and then pressing **Confirm** again to confirm deletion, or **Back** to cancel.
Bookmarks are stored in the `.crosspoint/bookmarks` folder in the JSON format.
Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

+3
View File
@@ -101,6 +101,9 @@ static uint8_t lookupKernClass(const EpdKernClassEntry* entries, const uint16_t
}
int8_t EpdFont::getKerning(const uint32_t leftCp, const uint32_t rightCp) const {
if (utf8IsCjkBreakable(leftCp) || utf8IsCjkBreakable(rightCp)) {
return 0;
}
if (!data->kernMatrix) {
return 0;
}
+81 -41
View File
@@ -115,6 +115,9 @@ void SdCardFont::freeStyleAll(PerStyle& s) {
freeStyleMiniData(s);
delete[] s.fullIntervals;
s.fullIntervals = nullptr;
delete[] s.bmpIntervals;
s.bmpIntervals = nullptr;
s.intervalsAreBmp16 = false;
freeStyleKernLigatureData(s);
s.present = false;
}
@@ -516,59 +519,94 @@ bool SdCardFont::load(const char* path) {
styleCount_ = styleCount;
contentHash_ = hash;
// Load full intervals into RAM for each present style
// Load full intervals into RAM for each present style. BMP-only fonts with
// fewer than 65536 glyphs use a compact 6-byte interval table instead of the
// on-disk 12-byte table; large sparse CJK subsets otherwise keep tens of KB
// of always-resident heap just for lookup metadata.
for (uint8_t i = 0; i < MAX_STYLES; i++) {
auto& s = styles_[i];
if (!s.present) continue;
s.fullIntervals = new (std::nothrow) EpdUnicodeInterval[s.header.intervalCount];
if (!s.fullIntervals) {
LOG_ERR("SDCF", "Failed to allocate %u intervals for style %u", s.header.intervalCount, i);
freeAll();
return false;
}
if (!file.seekSet(s.intervalsFileOffset)) {
LOG_ERR("SDCF", "Failed to seek to intervals for style %u", i);
freeAll();
return false;
}
size_t intervalsBytes = s.header.intervalCount * sizeof(EpdUnicodeInterval);
if (file.read(reinterpret_cast<uint8_t*>(s.fullIntervals), intervalsBytes) != static_cast<int>(intervalsBytes)) {
LOG_ERR("SDCF", "Failed to read intervals for style %u", i);
freeAll();
return false;
}
// Validate interval contents before any later code (findGlobalGlyphIndex,
// glyph reads) trusts them. A malformed file could otherwise drive
// out-of-range glyph indices into bogus on-disk reads.
{
uint32_t expectedOffset = 0;
uint32_t prevLast = 0;
bool canUseBmp16 = s.header.glyphCount <= UINT16_MAX;
uint32_t expectedOffset = 0;
uint32_t prevLast = 0;
EpdUnicodeInterval iv{};
for (uint32_t j = 0; j < s.header.intervalCount; ++j) {
if (file.read(reinterpret_cast<uint8_t*>(&iv), sizeof(iv)) != sizeof(iv)) {
LOG_ERR("SDCF", "Failed to read interval %u for style %u", j, i);
freeAll();
return false;
}
if (iv.first > iv.last) {
LOG_ERR("SDCF", "Style %u: invalid interval %u (first 0x%lX > last 0x%lX)", i, j,
static_cast<unsigned long>(iv.first), static_cast<unsigned long>(iv.last));
file.close();
freeAll();
return false;
}
const uint32_t span = iv.last - iv.first + 1;
const bool overlapsPrev = (j > 0 && iv.first <= prevLast);
const bool spanTooBig = (span > s.header.glyphCount);
const bool offsetMismatch = (iv.offset != expectedOffset);
const bool offsetOverruns = (iv.offset > s.header.glyphCount - span);
if (overlapsPrev || spanTooBig || offsetMismatch || offsetOverruns) {
LOG_ERR("SDCF", "Style %u: invalid interval layout at %u (overlap=%d span=%u offMis=%d offOver=%d)", i, j,
overlapsPrev, span, offsetMismatch, offsetOverruns);
file.close();
freeAll();
return false;
}
if (iv.first > UINT16_MAX || iv.last > UINT16_MAX || iv.offset > UINT16_MAX) {
canUseBmp16 = false;
}
expectedOffset += span;
prevLast = iv.last;
}
if (!file.seekSet(s.intervalsFileOffset)) {
LOG_ERR("SDCF", "Failed to seek back to intervals for style %u", i);
freeAll();
return false;
}
if (canUseBmp16) {
s.bmpIntervals = new (std::nothrow) PerStyle::BmpInterval16[s.header.intervalCount];
if (!s.bmpIntervals) {
LOG_ERR("SDCF", "Failed to allocate compact intervals for style %u", i);
freeAll();
return false;
}
for (uint32_t j = 0; j < s.header.intervalCount; ++j) {
const auto& iv = s.fullIntervals[j];
if (iv.first > iv.last) {
LOG_ERR("SDCF", "Style %u: invalid interval %u (first 0x%lX > last 0x%lX)", i, j,
static_cast<unsigned long>(iv.first), static_cast<unsigned long>(iv.last));
file.close();
if (file.read(reinterpret_cast<uint8_t*>(&iv), sizeof(iv)) != sizeof(iv)) {
LOG_ERR("SDCF", "Failed to read compact interval %u for style %u", j, i);
freeAll();
return false;
}
const uint32_t span = iv.last - iv.first + 1;
const bool overlapsPrev = (j > 0 && iv.first <= prevLast);
const bool spanTooBig = (span > s.header.glyphCount);
const bool offsetMismatch = (iv.offset != expectedOffset);
const bool offsetOverruns = (iv.offset > s.header.glyphCount - span);
if (overlapsPrev || spanTooBig || offsetMismatch || offsetOverruns) {
LOG_ERR("SDCF", "Style %u: invalid interval layout at %u (overlap=%d span=%u offMis=%d offOver=%d)", i, j,
overlapsPrev, span, offsetMismatch, offsetOverruns);
file.close();
freeAll();
return false;
}
expectedOffset += span;
prevLast = iv.last;
s.bmpIntervals[j] = {static_cast<uint16_t>(iv.first), static_cast<uint16_t>(iv.last),
static_cast<uint16_t>(iv.offset)};
}
s.intervalsAreBmp16 = true;
} else {
s.fullIntervals = new (std::nothrow) EpdUnicodeInterval[s.header.intervalCount];
if (!s.fullIntervals) {
LOG_ERR("SDCF", "Failed to allocate %u intervals for style %u", s.header.intervalCount, i);
freeAll();
return false;
}
size_t intervalsBytes = s.header.intervalCount * sizeof(EpdUnicodeInterval);
if (file.read(reinterpret_cast<uint8_t*>(s.fullIntervals), intervalsBytes) != static_cast<int>(intervalsBytes)) {
LOG_ERR("SDCF", "Failed to read intervals for style %u", i);
freeAll();
return false;
}
}
@@ -603,13 +641,15 @@ int32_t SdCardFont::findGlobalGlyphIndex(const PerStyle& s, uint32_t codepoint)
int right = static_cast<int>(s.header.intervalCount) - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
const auto& interval = s.fullIntervals[mid];
if (codepoint < interval.first) {
const uint32_t first = s.intervalsAreBmp16 ? s.bmpIntervals[mid].first : s.fullIntervals[mid].first;
const uint32_t last = s.intervalsAreBmp16 ? s.bmpIntervals[mid].last : s.fullIntervals[mid].last;
if (codepoint < first) {
right = mid - 1;
} else if (codepoint > interval.last) {
} else if (codepoint > last) {
left = mid + 1;
} else {
return static_cast<int32_t>(interval.offset + (codepoint - interval.first));
const uint32_t offset = s.intervalsAreBmp16 ? s.bmpIntervals[mid].offset : s.fullIntervals[mid].offset;
return static_cast<int32_t>(offset + (codepoint - first));
}
}
return -1;
@@ -1257,7 +1297,7 @@ const EpdGlyph* SdCardFont::onGlyphMiss(void* ctx, uint32_t codepoint) {
if (!self->loaded_ || styleIdx >= MAX_STYLES || !self->styles_[styleIdx].present) return nullptr;
const auto& s = self->styles_[styleIdx];
if (!s.fullIntervals) return nullptr;
if (!s.fullIntervals && !s.bmpIntervals) return nullptr;
// Check overflow cache first (matching both codepoint and style)
for (uint32_t i = 0; i < self->overflowCount_; i++) {
+11 -3
View File
@@ -58,9 +58,9 @@ class SdCardFont {
// Returns true if advance table is populated for at least one style.
bool hasAdvanceTable() const;
// Free mini data for all styles, restore stub EpdFontData.
// Also clears the temporary advance table (built per layout pass) but
// preserves the persistent advance cache (reused across passes).
// Free mini data for all styles and restore stub EpdFontData.
// Preserves the persistent advance cache so repeated layout passes can reuse
// previously fetched metrics.
void clearCache();
// Drop the persistent advance cache. Call when unloading the SD font or
@@ -140,6 +140,14 @@ class SdCardFont {
// Full intervals loaded from file (kept in RAM for codepoint lookup)
EpdUnicodeInterval* fullIntervals = nullptr;
struct BmpInterval16 {
uint16_t first;
uint16_t last;
uint16_t offset;
} __attribute__((packed));
static_assert(sizeof(BmpInterval16) == 6, "BmpInterval16 must remain compact");
BmpInterval16* bmpIntervals = nullptr;
bool intervalsAreBmp16 = false;
// Persistent kern-class + ligature tables (lazy-loaded on first prewarm).
// The full kern MATRIX is NOT resident — on Literata-class fonts a single
+5 -9
View File
@@ -34,19 +34,15 @@ bool SdCardFontManager::loadFamily(const SdCardFontFamilyInfo& family, GfxRender
unloadAll(renderer);
}
// Select by ordinal position: sort available sizes, then map the font size
// enum (SMALL=0 .. EXTRA_LARGE=3) to the corresponding slot. When the
// family has fewer sizes than 4, clamp to the last available size.
auto sizes = family.availableSizes();
if (sizes.empty()) {
// Select the physical point size closest to the built-in reader sizes. Some
// CJK font packs only ship larger sizes, so ordinal selection can make
// MEDIUM load 18pt+ and produce oversized pages on small devices.
const SdCardFontFileInfo* selected = family.findClosestReaderSize(fontSizeEnum);
if (!selected) {
LOG_ERR("SDMGR", "Family %s has no files to load", family.name.c_str());
return false;
}
uint8_t idx = fontSizeEnum;
if (idx >= sizes.size()) idx = sizes.size() - 1;
const SdCardFontFileInfo* selected = family.findFile(sizes[idx]);
auto* font = new (std::nothrow) SdCardFont();
if (!font) {
LOG_ERR("SDMGR", "Failed to allocate SdCardFont for %s", selected->path.c_str());
+5 -5
View File
@@ -15,10 +15,10 @@ class SdCardFontManager {
SdCardFontManager(const SdCardFontManager&) = delete;
SdCardFontManager& operator=(const SdCardFontManager&) = delete;
// Load the font file matching fontSizeEnum (SMALL=0 .. EXTRA_LARGE=3) by
// ordinal position in the family's sorted size list. Only one .cpfont file
// is loaded; other sizes remain on disk. This keeps resident interval +
// kern/ligature tables to one size's worth of memory.
// Load the font file whose physical point size is closest to the reader
// fontSizeEnum (SMALL=12, MEDIUM=14, LARGE=16, EXTRA_LARGE=18). Only one
// .cpfont file is loaded; other sizes remain on disk. This keeps resident
// interval + kern/ligature tables to one size's worth of memory.
// Returns true on success.
bool loadFamily(const SdCardFontFamilyInfo& family, GfxRenderer& renderer, uint8_t fontSizeEnum);
@@ -32,7 +32,7 @@ class SdCardFontManager {
// Get name of currently loaded family (empty if none).
const std::string& currentFamilyName() const { return loadedFamilyName_; };
// Point size that was actually loaded (closest match to targetPtSize).
// Point size that was actually loaded.
// 0 if nothing loaded.
uint8_t currentPointSize() const { return loadedPointSize_; };
+54
View File
@@ -15,6 +15,60 @@ const SdCardFontFileInfo* SdCardFontFamilyInfo::findFile(uint8_t size, uint8_t s
return nullptr;
}
const SdCardFontFileInfo* SdCardFontFamilyInfo::findClosestReaderSize(const uint8_t fontSizeEnum,
const uint8_t style) const {
if (files.empty()) return nullptr;
// Collect sizes matching the requested style, sorted ascending.
std::vector<uint8_t> sizes;
for (const auto& f : files) {
if (f.style != style) continue;
sizes.push_back(f.pointSize);
}
if (sizes.empty()) return nullptr;
std::sort(sizes.begin(), sizes.end());
// When the family provides at least 4 sizes, use ordinal (index-based)
// selection so custom-built font sets (e.g. 10/12/14/16) map SMALL to
// the smallest file, not to a hardcoded 12pt target.
if (sizes.size() >= 4) {
uint8_t idx = fontSizeEnum;
if (idx >= sizes.size()) idx = sizes.size() - 1;
return findFile(sizes[idx], style);
}
// Fewer sizes than enum slots (e.g. CJK packs with only 2-3 sizes):
// fall back to closest-match against the built-in reader targets.
uint8_t target = 14;
switch (fontSizeEnum) {
case 0:
target = 12;
break;
case 2:
target = 16;
break;
case 3:
target = 18;
break;
case 1:
default:
target = 14;
break;
}
const SdCardFontFileInfo* best = nullptr;
uint8_t bestDelta = 255;
for (const auto& f : files) {
if (f.style != style) continue;
const uint8_t delta = f.pointSize > target ? f.pointSize - target : target - f.pointSize;
if (!best || delta < bestDelta || (delta == bestDelta && f.pointSize < best->pointSize)) {
best = &f;
bestDelta = delta;
}
}
return best;
}
bool SdCardFontFamilyInfo::hasSize(uint8_t size) const {
for (const auto& f : files) {
if (f.pointSize == size) return true;
+1
View File
@@ -18,6 +18,7 @@ struct SdCardFontFamilyInfo {
std::vector<SdCardFontFileInfo> files;
const SdCardFontFileInfo* findFile(uint8_t size, uint8_t style = 0) const;
const SdCardFontFileInfo* findClosestReaderSize(uint8_t fontSizeEnum, uint8_t style = 0) const;
bool hasSize(uint8_t size) const;
std::vector<uint8_t> availableSizes() const;
};
+1 -1
View File
@@ -248,7 +248,7 @@ unmerged_intervals = sorted(intervals + add_ints)
intervals = []
unvalidated_intervals = []
for i_start, i_end in unmerged_intervals:
if len(unvalidated_intervals) > 0 and i_start + 1 <= unvalidated_intervals[-1][1]:
if len(unvalidated_intervals) > 0 and i_start <= unvalidated_intervals[-1][1] + 1:
unvalidated_intervals[-1] = (unvalidated_intervals[-1][0], max(unvalidated_intervals[-1][1], i_end))
continue
unvalidated_intervals.append((i_start, i_end))
+3 -2
View File
@@ -40,7 +40,8 @@ INTERVAL_PRESETS = {
"ascii": [(0x0020, 0x007E)],
"latin1": [(0x0080, 0x00FF)],
"latin-ext": [(0x0020, 0x007E), (0x0080, 0x00FF), (0x0100, 0x024F),
(0x1E00, 0x1EFF), (0x2000, 0x206F), (0xFB00, 0xFB06)],
(0x02B0, 0x02FF), (0x1E00, 0x1EFF), (0x2000, 0x206F),
(0xFB00, 0xFB06)],
"greek": [(0x0370, 0x03FF), (0x1F00, 0x1FFF)],
"cyrillic": [(0x0400, 0x04FF), (0x0500, 0x052F)],
"hebrew": [(0x0590, 0x05FF), (0xFB1D, 0xFB4F)],
@@ -62,7 +63,7 @@ INTERVAL_PRESETS = {
# Composite preset for English-language literary fiction including scifi/popsci.
# Greek for physics terms, math operators, geometric shapes, uncommon
# dialogue punctuation, CJK quote marks, miscellaneous symbols (♪♫♬), dingbats.
"reading": [(0x0020, 0x024F), (0x0300, 0x036F), (0x0370, 0x03FF),
"reading": [(0x0020, 0x024F), (0x02B0, 0x02FF), (0x0300, 0x036F), (0x0370, 0x03FF),
(0x0400, 0x04FF), (0x1E00, 0x1EFF), (0x2000, 0x206F),
(0x2070, 0x209F), (0x20A0, 0x20CF), (0x2150, 0x218F),
(0x2190, 0x21FF), (0x2200, 0x22FF), (0x2500, 0x257F),
+4 -2
View File
@@ -5,6 +5,7 @@
#include <JpegToBmpConverter.h>
#include <Logging.h>
#include <PngToBmpConverter.h>
#include <Utf8.h>
#include <ZipFile.h>
#include "Epub/parsers/ContainerParser.h"
@@ -73,8 +74,9 @@ bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata, const
return false;
}
// Grab data from opfParser into epub
bookMetadata.title = opfParser.title;
// Grab data from opfParser into epub. Normalize titles to NFC so NFD (combining
// mark) text renders correctly — the device fonts have no mark positioning.
bookMetadata.title = utf8ComposeNfc(opfParser.title);
bookMetadata.author = opfParser.author;
bookMetadata.language = opfParser.language;
bookMetadata.coverItemHref = opfParser.coverItemHref;
+5 -2
View File
@@ -2,6 +2,7 @@
#include <Logging.h>
#include <Serialization.h>
#include <Utf8.h>
#include <ZipFile.h>
#include <deque>
@@ -9,7 +10,7 @@
#include "FsHelpers.h"
namespace {
constexpr uint8_t BOOK_CACHE_VERSION = 7;
constexpr uint8_t BOOK_CACHE_VERSION = 8; // v8: TOC/book titles stored NFC-composed
constexpr char bookBinFile[] = "/book.bin";
constexpr char tmpSpineBinFile[] = "/spine.bin.tmp";
constexpr char tmpTocBinFile[] = "/toc.bin.tmp";
@@ -364,7 +365,9 @@ void BookMetadataCache::createTocEntry(const std::string& title, const std::stri
}
}
const TocEntry entry(title, href, anchor, level, spineIndex);
// Compose the title to NFC at index time so the cache stores precomposed glyphs;
// device fonts have no combining-mark positioning, so NFD titles render broken.
const TocEntry entry(utf8ComposeNfc(title), href, anchor, level, spineIndex);
writeTocEntry(tocFile, entry);
tocCount++;
}
+20 -3
View File
@@ -6,6 +6,20 @@
#include <new>
namespace {
template <typename Predicate>
void renderFilteredPageElements(const std::vector<std::shared_ptr<PageElement>>& elements, GfxRenderer& renderer,
const int fontId, const int xOffset, const int yOffset, Predicate&& predicate) {
for (const auto& element : elements) {
if (predicate(*element)) {
element->render(renderer, fontId, xOffset, yOffset);
}
}
}
} // namespace
void PageLine::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) {
block->render(renderer, fontId, xPos + xOffset, yPos + yOffset);
}
@@ -93,9 +107,12 @@ std::unique_ptr<PageHorizontalRule> PageHorizontalRule::deserialize(HalFile& fil
}
void Page::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) const {
for (auto& element : elements) {
element->render(renderer, fontId, xOffset, yOffset);
}
renderFilteredPageElements(elements, renderer, fontId, xOffset, yOffset, [](const PageElement&) { return true; });
}
void Page::renderImages(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) const {
renderFilteredPageElements(elements, renderer, fontId, xOffset, yOffset,
[](const PageElement& element) { return element.getTag() == TAG_PageImage; });
}
bool Page::serialize(HalFile& file) const {
+1
View File
@@ -88,6 +88,7 @@ class Page {
}
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const;
void renderImages(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const;
bool serialize(HalFile& file) const;
static std::unique_ptr<Page> deserialize(HalFile& file);
+259 -35
View File
@@ -24,6 +24,7 @@ constexpr size_t RTL_PARAGRAPH_PROBE_WORDS = 3;
// Per-word: scan enough chars to see through leading neutrals (quotes, numbers)
// before giving up. 64 is a hedge for pathological cases like long numeric tokens.
constexpr int RTL_PER_WORD_PROBE_DEPTH = 64;
constexpr size_t MIN_JUSTIFY_GAPS = 1;
// Byte-level pre-check: Hebrew UTF-8 lead bytes 0xD6-0xD7, Arabic/Syriac 0xD8-0xDB.
bool mayContainRtlBytes(const char* str) {
@@ -57,6 +58,134 @@ uint32_t lastCodepoint(const std::string& word) {
bool containsSoftHyphen(const std::string& word) { return word.find(SOFT_HYPHEN_UTF8) != std::string::npos; }
bool isNoBreakBeforeCjkPunctuation(const uint32_t cp) {
switch (cp) {
case '.':
case ',':
case ':':
case ';':
case '!':
case '?':
case ')':
case ']':
case '}':
case 0x00BB: // »
case 0x2019: //
case 0x201D: // ”
case 0x3001: // 、
case 0x3002: // 。
case 0x3009: // 〉
case 0x300B: // 》
case 0x300D: // 」
case 0x300F: // 』
case 0x3011: // 】
case 0x3015: //
case 0x3017: // 〗
case 0x3019: // 〙
case 0x301B: // 〛
case 0xFF01: //
case 0xFF09: //
case 0xFF0C: //
case 0xFF0E: //
case 0xFF1A: //
case 0xFF1B: //
case 0xFF1F: //
case 0xFF3D: //
case 0xFF5D: //
return true;
default:
return false;
}
}
bool isNoBreakAfterCjkPunctuation(const uint32_t cp) {
switch (cp) {
case '(':
case '[':
case '{':
case 0x00AB: // «
case 0x2018: //
case 0x201C: // “
case 0x3008: // 〈
case 0x300A: // 《
case 0x300C: // 「
case 0x300E: // 『
case 0x3010: // 【
case 0x3014: //
case 0x3016: // 〖
case 0x3018: // 〘
case 0x301A: // 〚
case 0xFF08: //
case 0xFF3B: //
case 0xFF5B: //
return true;
default:
return false;
}
}
bool containsCjkBreakableCodepoint(const std::string& text) {
const auto* ptr = reinterpret_cast<const unsigned char*>(text.c_str());
while (*ptr) {
const uint32_t cp = utf8NextCodepoint(&ptr);
if (utf8IsCjkBreakable(cp)) {
return true;
}
}
return false;
}
bool hasCjkBreakOpportunityBetween(const uint32_t leftCp, const uint32_t rightCp) {
if (!utf8IsCjkBreakable(leftCp) && !utf8IsCjkBreakable(rightCp)) return false;
if (isNoBreakAfterCjkPunctuation(leftCp) || isNoBreakBeforeCjkPunctuation(rightCp)) return false;
if (utf8IsCombiningMark(rightCp)) return false;
return true;
}
std::vector<size_t> cjkCharacterBreakByteOffsets(const std::string& text) {
struct CodepointBoundary {
uint32_t cp;
size_t endOffset;
};
std::vector<CodepointBoundary> codepoints;
codepoints.reserve(text.size());
bool hasCjkBreakable = false;
const auto* ptr = reinterpret_cast<const unsigned char*>(text.c_str());
const auto* const start = ptr;
while (*ptr) {
const uint32_t cp = utf8NextCodepoint(&ptr);
if (cp == 0) break;
if (utf8IsCjkBreakable(cp)) {
hasCjkBreakable = true;
}
codepoints.push_back({cp, static_cast<size_t>(ptr - start)});
}
if (!hasCjkBreakable || codepoints.size() < 2) return {};
std::vector<size_t> allowedOffsets;
allowedOffsets.reserve(codepoints.size() - 1);
for (size_t i = 0; i + 1 < codepoints.size(); ++i) {
const uint32_t current = codepoints[i].cp;
const uint32_t next = codepoints[i + 1].cp;
if (!hasCjkBreakOpportunityBetween(current, next)) continue;
allowedOffsets.push_back(codepoints[i].endOffset);
}
return allowedOffsets;
}
int computeJustifyExtra(const int spareSpace, const size_t gapCount) {
if (gapCount < MIN_JUSTIFY_GAPS || spareSpace <= 0) return 0;
// Distribute the spare space evenly across gaps. Do NOT bail out to 0 when the
// per-gap stretch is large: a sparse line (few words on a wide page) legitimately
// needs big gaps to reach the margin. Returning 0 there disables justification for
// that line, leaving it right-aligned (RTL) / left-aligned (LTR) — the mismatched
// alignment bug. Match the un-capped behavior of the old code.
return spareSpace / static_cast<int>(gapCount);
}
// Removes every soft hyphen in-place so rendered glyphs match measured widths.
void stripSoftHyphensInPlace(std::string& word) {
size_t pos = 0;
@@ -125,6 +254,14 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle,
const bool attachToPrevious) {
if (word.empty()) return;
// The device fonts carry no combining-mark positioning, so EPUB text stored in NFD
// (a base letter followed by separate combining accents -- common for Vietnamese,
// and used for many EPUB <h1> chapter headings) renders with the marks detached or
// misplaced. Compose to NFC here, the single funnel every word passes through, so a
// precomposed glyph is used instead. This runs once per word at layout time (the
// result is cached in the section file) and is a cheap no-op for mark-free text.
word = utf8ComposeNfc(word);
EpdFontFamily::Style baseStyle = fontStyle;
if (underline) {
baseStyle = static_cast<EpdFontFamily::Style>(baseStyle | EpdFontFamily::UNDERLINE);
@@ -132,12 +269,54 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle,
const bool wordStartsRtl = !hasRtlWord && mayContainRtlBytes(word.c_str()) &&
BidiUtils::startsWithRtl(word.c_str(), RTL_PER_WORD_PROBE_DEPTH);
const auto pushToken = [&](std::string token, const bool continues, const bool noSpaceBefore,
const bool isFocusSuffix) {
words.push_back(std::move(token));
wordStyles.push_back(baseStyle);
wordContinues.push_back(continues);
wordNoSpaceBefore.push_back(noSpaceBefore);
wordIsFocusSuffix.push_back(isFocusSuffix);
};
bool effectiveAttachToPrevious = attachToPrevious;
bool effectiveNoSpaceBefore = false;
if (attachToPrevious && !words.empty() &&
hasCjkBreakOpportunityBetween(lastCodepoint(words.back()), firstCodepoint(word))) {
effectiveAttachToPrevious = false;
effectiveNoSpaceBefore = true;
}
if (auto breakOffsets = cjkCharacterBreakByteOffsets(word); !breakOffsets.empty()) {
bool firstToken = true;
size_t tokenStart = 0;
for (const size_t breakOffset : breakOffsets) {
if (breakOffset <= tokenStart || breakOffset > word.size()) continue;
pushToken(word.substr(tokenStart, breakOffset - tokenStart), firstToken ? effectiveAttachToPrevious : false,
firstToken ? effectiveNoSpaceBefore : true, false);
firstToken = false;
tokenStart = breakOffset;
}
if (tokenStart < word.size()) {
pushToken(word.substr(tokenStart), firstToken ? effectiveAttachToPrevious : false,
firstToken ? effectiveNoSpaceBefore : true, false);
}
if (wordStartsRtl) {
hasRtlWord = true;
}
return;
}
if (containsCjkBreakableCodepoint(word)) {
pushToken(std::move(word), effectiveAttachToPrevious, effectiveNoSpaceBefore, false);
if (wordStartsRtl) {
hasRtlWord = true;
}
return;
}
// Already-bold text should stay fully bold; focus splitting would make its suffix regular later.
if (!this->focusReadingEnabled || (baseStyle & EpdFontFamily::BOLD) != 0) {
words.push_back(std::move(word));
wordStyles.push_back(baseStyle);
wordContinues.push_back(attachToPrevious);
wordIsFocusSuffix.push_back(false);
pushToken(std::move(word), effectiveAttachToPrevious, effectiveNoSpaceBefore, false);
if (wordStartsRtl) {
hasRtlWord = true;
}
@@ -166,17 +345,19 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle,
words.reserve(newCapacity);
wordStyles.reserve(newCapacity);
wordContinues.reserve(newCapacity);
wordNoSpaceBefore.reserve(newCapacity);
wordIsFocusSuffix.reserve(newCapacity);
}
// Lambda helper to process and push individual sub-segments of the string
// Use std::string_view to avoid heap allocations when slicing
auto processSegment = [&](std::string_view segment, bool isWord, bool attach) {
auto processSegment = [&](std::string_view segment, bool isWord, bool attach, bool noSpaceBefore) {
if (!isWord) {
// Punctuation and Numbers stay regular
words.emplace_back(segment);
wordStyles.push_back(baseStyle);
wordContinues.push_back(attach);
wordNoSpaceBefore.push_back(noSpaceBefore);
wordIsFocusSuffix.push_back(false);
} else {
size_t charCount = 0;
@@ -198,6 +379,7 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle,
words.emplace_back(segment);
wordStyles.push_back(static_cast<EpdFontFamily::Style>(baseStyle | EpdFontFamily::BOLD));
wordContinues.push_back(attach);
wordNoSpaceBefore.push_back(noSpaceBefore);
wordIsFocusSuffix.push_back(false);
} else {
countPtr = reinterpret_cast<const unsigned char*>(segment.data());
@@ -210,12 +392,14 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle,
words.emplace_back(segment.substr(0, splitByteOffset));
wordStyles.push_back(static_cast<EpdFontFamily::Style>(baseStyle | EpdFontFamily::BOLD));
wordContinues.push_back(attach);
wordNoSpaceBefore.push_back(noSpaceBefore);
wordIsFocusSuffix.push_back(false);
// Regular suffix - marked so extractLine can merge it back into single TextBlock entry
words.emplace_back(segment.substr(splitByteOffset));
wordStyles.push_back(baseStyle);
wordContinues.push_back(true);
wordNoSpaceBefore.push_back(false);
wordIsFocusSuffix.push_back(true);
}
}
@@ -243,7 +427,8 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle,
// Only the very first segment inherits the original attachToPrevious flag.
// Every subsequent segment MUST attach=true so it glues seamlessly to the prefix.
processSegment(segment, inWordSegment, isFirstSegment ? attachToPrevious : true);
processSegment(segment, inWordSegment, isFirstSegment ? effectiveAttachToPrevious : true,
isFirstSegment ? effectiveNoSpaceBefore : false);
// Setup for the next segment
segmentStart = currentCpStart;
@@ -255,7 +440,8 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle,
// Process the final remaining segment
size_t segmentLen = end - segmentStart;
std::string_view segment(reinterpret_cast<const char*>(segmentStart), segmentLen);
processSegment(segment, inWordSegment, isFirstSegment ? attachToPrevious : true);
processSegment(segment, inWordSegment, isFirstSegment ? effectiveAttachToPrevious : true,
isFirstSegment ? effectiveNoSpaceBefore : false);
if (wordStartsRtl) {
hasRtlWord = true;
}
@@ -324,14 +510,16 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo
std::vector<size_t> lineBreakIndices;
if (hyphenationEnabled) {
// Use greedy layout that can split words mid-loop when a hyphenated prefix fits.
lineBreakIndices = computeHyphenatedLineBreaks(renderer, fontId, pageWidth, wordWidths, wordContinues);
lineBreakIndices =
computeHyphenatedLineBreaks(renderer, fontId, pageWidth, wordWidths, wordContinues, wordNoSpaceBefore);
} else {
lineBreakIndices = computeLineBreaks(renderer, fontId, pageWidth, wordWidths, wordContinues);
lineBreakIndices = computeLineBreaks(renderer, fontId, pageWidth, wordWidths, wordContinues, wordNoSpaceBefore);
}
const size_t lineCount = includeLastLine ? lineBreakIndices.size() : lineBreakIndices.size() - 1;
for (size_t i = 0; i < lineCount; ++i) {
extractLine(i, pageWidth, wordWidths, wordContinues, lineBreakIndices, processLine, renderer, fontId);
extractLine(i, pageWidth, wordWidths, wordContinues, wordNoSpaceBefore, lineBreakIndices, processLine, renderer,
fontId);
}
// Remove consumed words so size() reflects only remaining words
@@ -340,6 +528,7 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo
words.erase(words.begin(), words.begin() + consumed);
wordStyles.erase(wordStyles.begin(), wordStyles.begin() + consumed);
wordContinues.erase(wordContinues.begin(), wordContinues.begin() + consumed);
wordNoSpaceBefore.erase(wordNoSpaceBefore.begin(), wordNoSpaceBefore.begin() + consumed);
wordIsFocusSuffix.erase(wordIsFocusSuffix.begin(), wordIsFocusSuffix.begin() + consumed);
}
}
@@ -356,7 +545,8 @@ std::vector<uint16_t> ParsedText::calculateWordWidths(const GfxRenderer& rendere
}
std::vector<size_t> ParsedText::computeLineBreaks(const GfxRenderer& renderer, const int fontId, const int pageWidth,
std::vector<uint16_t>& wordWidths, std::vector<bool>& continuesVec) {
std::vector<uint16_t>& wordWidths, std::vector<bool>& continuesVec,
std::vector<bool>& noSpaceBeforeVec) {
if (words.empty()) {
return {};
}
@@ -395,7 +585,9 @@ std::vector<size_t> ParsedText::computeLineBreaks(const GfxRenderer& renderer, c
for (size_t j = i; j < totalWordCount; ++j) {
// Add space before word j, unless it's the first word on the line or a continuation
int gap = 0;
if (j > static_cast<size_t>(i) && !continuesVec[j]) {
if (j > static_cast<size_t>(i) && noSpaceBeforeVec[j]) {
gap = 0;
} else if (j > static_cast<size_t>(i) && !continuesVec[j]) {
gap =
renderer.getSpaceAdvance(fontId, lastCodepoint(words[j - 1]), firstCodepoint(words[j]), wordStyles[j - 1]);
} else if (j > static_cast<size_t>(i) && continuesVec[j]) {
@@ -470,7 +662,8 @@ std::vector<size_t> ParsedText::computeLineBreaks(const GfxRenderer& renderer, c
// Builds break indices while opportunistically splitting the word that would overflow the current line.
std::vector<size_t> ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& renderer, const int fontId,
const int pageWidth, std::vector<uint16_t>& wordWidths,
std::vector<bool>& continuesVec) {
std::vector<bool>& continuesVec,
std::vector<bool>& noSpaceBeforeVec) {
const int firstLineIndent = resolveFirstLineIndent(true, renderer, fontId);
std::vector<size_t> lineBreakIndices;
@@ -488,7 +681,9 @@ std::vector<size_t> ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r
while (currentIndex < wordWidths.size()) {
const bool isFirstWord = currentIndex == lineStart;
int spacing = 0;
if (!isFirstWord && !continuesVec[currentIndex]) {
if (!isFirstWord && noSpaceBeforeVec[currentIndex]) {
spacing = 0;
} else if (!isFirstWord && !continuesVec[currentIndex]) {
spacing = renderer.getSpaceAdvance(fontId, lastCodepoint(words[currentIndex - 1]),
firstCodepoint(words[currentIndex]), wordStyles[currentIndex - 1]);
} else if (!isFirstWord && continuesVec[currentIndex]) {
@@ -618,6 +813,7 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl
// line, while "kilometer" moves to the next line.
// wordContinues[wordIndex] is intentionally left unchanged — the prefix keeps its original attachment.
wordContinues.insert(wordContinues.begin() + wordIndex + 1, false);
wordNoSpaceBefore.insert(wordNoSpaceBefore.begin() + wordIndex + 1, false);
// Update cached widths to reflect the new prefix/remainder pairing.
wordWidths[wordIndex] = static_cast<uint16_t>(chosenWidth);
@@ -627,7 +823,8 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl
}
void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const std::vector<uint16_t>& wordWidths,
const std::vector<bool>& continuesVec, const std::vector<size_t>& lineBreakIndices,
const std::vector<bool>& continuesVec, const std::vector<bool>& noSpaceBeforeVec,
const std::vector<size_t>& lineBreakIndices,
const std::function<void(std::shared_ptr<TextBlock>)>& processLine,
const GfxRenderer& renderer, const int fontId) {
const size_t lineBreak = lineBreakIndices[breakIndex];
@@ -660,7 +857,11 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
for (size_t wordIdx = 0; wordIdx < lineWordCount; wordIdx++) {
lineWordWidthSum += wordWidths[lastBreakAt + wordIdx];
// Count gaps: each word after the first creates a gap, unless it's a continuation
if (wordIdx > 0 && !continuesVec[lastBreakAt + wordIdx]) {
if (wordIdx > 0 && noSpaceBeforeVec[lastBreakAt + wordIdx]) {
// Unicode break opportunity with no inserted Latin-style space. It is still
// a stretchable gap for justified CJK/Korean text.
actualGapCount++;
} else if (wordIdx > 0 && !continuesVec[lastBreakAt + wordIdx]) {
actualGapCount++;
totalNaturalGaps += renderer.getSpaceAdvance(fontId, lastCodepoint(lineWords[wordIdx - 1]),
firstCodepoint(lineWords[wordIdx]), lineWordStyles[wordIdx - 1]);
@@ -689,8 +890,8 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
// For justified text, compute per-gap extra to distribute remaining space evenly
const int spareSpace = effectivePageWidth - lineWordWidthSum - totalNaturalGaps;
const int justifyExtra = (effectiveAlignment == CssTextAlign::Justify && !isLastLine && actualGapCount >= 1)
? spareSpace / static_cast<int>(actualGapCount)
const int justifyExtra = (effectiveAlignment == CssTextAlign::Justify && !isLastLine)
? computeJustifyExtra(spareSpace, actualGapCount)
: 0;
// BiDi processing: reorder words with UAX#9 in full-line context.
@@ -709,11 +910,13 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
reorderedStylesScratch.clear();
reorderedWidthsScratch.clear();
reorderedContinuesScratch.clear();
reorderedNoSpaceBeforeScratch.clear();
reorderedFocusSuffixScratch.clear();
reorderedWordsScratch.reserve(visualOrderScratch.size());
reorderedStylesScratch.reserve(visualOrderScratch.size());
reorderedWidthsScratch.reserve(visualOrderScratch.size());
reorderedContinuesScratch.reserve(visualOrderScratch.size());
reorderedNoSpaceBeforeScratch.reserve(visualOrderScratch.size());
reorderedFocusSuffixScratch.reserve(visualOrderScratch.size());
for (size_t i = 0; i < visualOrderScratch.size(); ++i) {
@@ -740,6 +943,7 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
}
}
reorderedContinuesScratch.push_back(continues);
reorderedNoSpaceBeforeScratch.push_back(!continues && noSpaceBeforeVec[lastBreakAt + src]);
}
int reorderedWordWidthSum = 0;
@@ -747,7 +951,11 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
int reorderedNaturalGaps = 0;
for (size_t wordIdx = 0; wordIdx < reorderedWidthsScratch.size(); wordIdx++) {
reorderedWordWidthSum += reorderedWidthsScratch[wordIdx];
if (wordIdx > 0 && !reorderedContinuesScratch[wordIdx]) {
if (wordIdx > 0 && reorderedNoSpaceBeforeScratch[wordIdx]) {
// Unicode break opportunity with no inserted Latin-style space. It is still
// a stretchable gap for justified CJK/Korean text.
reorderedGapCount++;
} else if (wordIdx > 0 && !reorderedContinuesScratch[wordIdx]) {
reorderedGapCount++;
reorderedNaturalGaps += renderer.getSpaceAdvance(fontId, lastCodepoint(reorderedWordsScratch[wordIdx - 1]),
firstCodepoint(reorderedWordsScratch[wordIdx]),
@@ -763,10 +971,9 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
}
const int reorderedSpare = effectivePageWidth - reorderedWordWidthSum - reorderedNaturalGaps;
const int reorderedJustifyExtra =
(effectiveAlignment == CssTextAlign::Justify && !isLastLine && reorderedGapCount >= 1)
? reorderedSpare / static_cast<int>(reorderedGapCount)
: 0;
const int reorderedJustifyExtra = (effectiveAlignment == CssTextAlign::Justify && !isLastLine)
? computeJustifyExtra(reorderedSpare, reorderedGapCount)
: 0;
const int justifyContribution = (effectiveAlignment == CssTextAlign::Justify && !isLastLine)
? reorderedJustifyExtra * static_cast<int>(reorderedGapCount)
@@ -799,15 +1006,20 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
int advance =
renderer.getKerning(fontId, lastCodepoint(reorderedWordsScratch[wordIdx]),
firstCodepoint(reorderedWordsScratch[wordIdx + 1]), reorderedStylesScratch[wordIdx]);
if (reorderedWordsScratch[wordIdx] == " " && reorderedContinuesScratch[wordIdx] &&
// wordIdx > 0 mirrors the gap accounting above (which skips index 0): a leading
// no-break space must not receive justifyExtra, or the line over-stretches by one
// gap and the last word is pushed past the right margin (issue #2185).
if (wordIdx > 0 && reorderedWordsScratch[wordIdx] == " " && reorderedContinuesScratch[wordIdx] &&
effectiveAlignment == CssTextAlign::Justify && !isLastLine) {
advance += reorderedJustifyExtra;
}
xpos += advance;
} else if (wordIdx + 1 < reorderedWidthsScratch.size()) {
int gap = renderer.getSpaceAdvance(fontId, lastCodepoint(reorderedWordsScratch[wordIdx]),
firstCodepoint(reorderedWordsScratch[wordIdx + 1]),
reorderedStylesScratch[wordIdx]);
const bool nextNoSpace = reorderedNoSpaceBeforeScratch[wordIdx + 1];
int gap = nextNoSpace ? 0
: renderer.getSpaceAdvance(fontId, lastCodepoint(reorderedWordsScratch[wordIdx]),
firstCodepoint(reorderedWordsScratch[wordIdx + 1]),
reorderedStylesScratch[wordIdx]);
if (effectiveAlignment == CssTextAlign::Justify && !isLastLine) {
gap += reorderedJustifyExtra;
}
@@ -839,18 +1051,23 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
// Cross-boundary kerning for continuation words
int advance = renderer.getKerning(fontId, lastCodepoint(lineWords[wordIdx]),
firstCodepoint(lineWords[wordIdx + 1]), lineWordStyles[wordIdx]);
if (lineWords[wordIdx] == " " && continuesVec[lastBreakAt + wordIdx] &&
// wordIdx > 0: see the LTR branch — a leading no-break space is not a justifiable gap.
if (wordIdx > 0 && lineWords[wordIdx] == " " && continuesVec[lastBreakAt + wordIdx] &&
effectiveAlignment == CssTextAlign::Justify && !isLastLine) {
advance += justifyExtra;
}
xpos -= advance;
} else {
int gap = 0;
bool nextNoSpace = false;
if (wordIdx + 1 < lineWordCount) {
gap = renderer.getSpaceAdvance(fontId, lastCodepoint(lineWords[wordIdx]),
firstCodepoint(lineWords[wordIdx + 1]), lineWordStyles[wordIdx]);
nextNoSpace = noSpaceBeforeVec[lastBreakAt + wordIdx + 1];
gap = nextNoSpace
? 0
: renderer.getSpaceAdvance(fontId, lastCodepoint(lineWords[wordIdx]),
firstCodepoint(lineWords[wordIdx + 1]), lineWordStyles[wordIdx]);
}
if (effectiveAlignment == CssTextAlign::Justify && !isLastLine) {
if (wordIdx + 1 < lineWordCount && effectiveAlignment == CssTextAlign::Justify && !isLastLine) {
gap += justifyExtra;
}
xpos -= gap;
@@ -873,18 +1090,25 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
int advance = wordWidths[lastBreakAt + wordIdx];
advance += renderer.getKerning(fontId, lastCodepoint(lineWords[wordIdx]),
firstCodepoint(lineWords[wordIdx + 1]), lineWordStyles[wordIdx]);
if (lineWords[wordIdx] == " " && continuesVec[lastBreakAt + wordIdx] &&
// wordIdx > 0 mirrors the gap accounting above (which skips index 0): a leading
// no-break space must not receive justifyExtra, or the line over-stretches by one
// gap and the last word is pushed past the right margin (issue #2185).
if (wordIdx > 0 && lineWords[wordIdx] == " " && continuesVec[lastBreakAt + wordIdx] &&
effectiveAlignment == CssTextAlign::Justify && !isLastLine) {
advance += justifyExtra;
}
xpos += advance;
} else {
int gap = 0;
bool nextNoSpace = false;
if (wordIdx + 1 < lineWordCount) {
gap = renderer.getSpaceAdvance(fontId, lastCodepoint(lineWords[wordIdx]),
firstCodepoint(lineWords[wordIdx + 1]), lineWordStyles[wordIdx]);
nextNoSpace = noSpaceBeforeVec[lastBreakAt + wordIdx + 1];
gap = nextNoSpace
? 0
: renderer.getSpaceAdvance(fontId, lastCodepoint(lineWords[wordIdx]),
firstCodepoint(lineWords[wordIdx + 1]), lineWordStyles[wordIdx]);
}
if (effectiveAlignment == CssTextAlign::Justify && !isLastLine) {
if (wordIdx + 1 < lineWordCount && effectiveAlignment == CssTextAlign::Justify && !isLastLine) {
gap += justifyExtra;
}
xpos += wordWidths[lastBreakAt + wordIdx] + gap;
+9 -4
View File
@@ -15,7 +15,8 @@ class GfxRenderer;
class ParsedText {
std::vector<std::string> words;
std::vector<EpdFontFamily::Style> wordStyles;
std::vector<bool> wordContinues; // true = word attaches to previous (no space before it)
std::vector<bool> wordContinues; // true = word attaches to previous with no break
std::vector<bool> wordNoSpaceBefore; // true = may break before token, but no synthetic space when joined
std::vector<bool> wordIsFocusSuffix; // true = token is the regular tail of a focus bold-prefix split
BlockStyle blockStyle;
bool extraParagraphSpacing;
@@ -27,18 +28,22 @@ class ParsedText {
std::vector<EpdFontFamily::Style> reorderedStylesScratch;
std::vector<uint16_t> reorderedWidthsScratch;
std::vector<bool> reorderedContinuesScratch;
std::vector<bool> reorderedNoSpaceBeforeScratch;
std::vector<bool> reorderedFocusSuffixScratch;
std::vector<uint16_t> visualOrderScratch;
int resolveFirstLineIndent(bool isFirstLine, const GfxRenderer& renderer, int fontId) const;
std::vector<size_t> computeLineBreaks(const GfxRenderer& renderer, int fontId, int pageWidth,
std::vector<uint16_t>& wordWidths, std::vector<bool>& continuesVec);
std::vector<uint16_t>& wordWidths, std::vector<bool>& continuesVec,
std::vector<bool>& noSpaceBeforeVec);
std::vector<size_t> computeHyphenatedLineBreaks(const GfxRenderer& renderer, int fontId, int pageWidth,
std::vector<uint16_t>& wordWidths, std::vector<bool>& continuesVec);
std::vector<uint16_t>& wordWidths, std::vector<bool>& continuesVec,
std::vector<bool>& noSpaceBeforeVec);
bool hyphenateWordAtIndex(size_t wordIndex, int availableWidth, const GfxRenderer& renderer, int fontId,
std::vector<uint16_t>& wordWidths, bool allowFallbackBreaks);
void extractLine(size_t breakIndex, int pageWidth, const std::vector<uint16_t>& wordWidths,
const std::vector<bool>& continuesVec, const std::vector<size_t>& lineBreakIndices,
const std::vector<bool>& continuesVec, const std::vector<bool>& noSpaceBeforeVec,
const std::vector<size_t>& lineBreakIndices,
const std::function<void(std::shared_ptr<TextBlock>)>& processLine, const GfxRenderer& renderer,
int fontId);
std::vector<uint16_t> calculateWordWidths(const GfxRenderer& renderer, int fontId);
+2 -1
View File
@@ -10,7 +10,8 @@
#include "parsers/ChapterHtmlSlimParser.h"
namespace {
constexpr uint8_t SECTION_FILE_VERSION = 26;
// v27: words NFC-composed at layout time; bump invalidates NFD section caches.
constexpr uint8_t SECTION_FILE_VERSION = 27;
constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint8_t) +
sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) + sizeof(bool) +
sizeof(uint8_t) + sizeof(bool) + sizeof(uint32_t) + sizeof(uint32_t) +
+51 -48
View File
@@ -13,54 +13,57 @@ struct EntityPair {
// Sorted lexicographically by key to allow binary search.
static constexpr EntityPair ENTITY_LOOKUP[] = {
{"&AElig;", "Æ"}, {"&Aacute;", "Á"}, {"&Acirc;", "Â"}, {"&Agrave;", "À"}, {"&Alpha;", "Α"},
{"&Aring;", "Å"}, {"&Atilde;", "Ã"}, {"&Auml;", "Ä"}, {"&Beta;", "Β"}, {"&Ccedil;", "Ç"},
{"&Chi;", "Χ"}, {"&Dagger;", ""}, {"&Delta;", "Δ"}, {"&ETH;", "Ð"}, {"&Eacute;", "É"},
{"&Ecirc;", "Ê"}, {"&Egrave;", "È"}, {"&Epsilon;", "Ε"}, {"&Eta;", "Η"}, {"&Euml;", "Ë"},
{"&Gamma;", "Γ"}, {"&Iacute;", "Í"}, {"&Icirc;", "Î"}, {"&Igrave;", "Ì"}, {"&Iota;", "Ι"},
{"&Iuml;", "Ï"}, {"&Kappa;", "Κ"}, {"&Lambda;", "Λ"}, {"&Mu;", "Μ"}, {"&Ntilde;", "Ñ"},
{"&Nu;", "Ν"}, {"&OElig;", "Œ"}, {"&Oacute;", "Ó"}, {"&Ocirc;", "Ô"}, {"&Ograve;", "Ò"},
{"&Omega;", "Ω"}, {"&Omicron;", "Ο"}, {"&Oslash;", "Ø"}, {"&Otilde;", "Õ"}, {"&Ouml;", "Ö"},
{"&Phi;", "Φ"}, {"&Pi;", "Π"}, {"&Prime;", ""}, {"&Psi;", "Ψ"}, {"&Rho;", "Ρ"},
{"&Scaron;", "Š"}, {"&Sigma;", "Σ"}, {"&THORN;", "Þ"}, {"&Tau;", "Τ"}, {"&Theta;", "Θ"},
{"&Uacute;", "Ú"}, {"&Ucirc;", "Û"}, {"&Ugrave;", "Ù"}, {"&Upsilon;", "Υ"}, {"&Uuml;", "Ü"},
{"&Xi;", "Ξ"}, {"&Yacute;", "Ý"}, {"&Yuml;", "Ÿ"}, {"&Zeta;", "Ζ"}, {"&aacute;", "á"},
{"&acirc;", "â"}, {"&acute;", "´"}, {"&aelig;", "æ"}, {"&agrave;", "à"}, {"&alpha;", "α"},
{"&amp;", "&"}, {"&and;", ""}, {"&ang;", ""}, {"&aring;", "å"}, {"&asymp;", ""},
{"&atilde;", "ã"}, {"&auml;", "ä"}, {"&bdquo;", ""}, {"&beta;", "β"}, {"&brvbar;", "¦"},
{"&bull;", ""}, {"&cap;", ""}, {"&ccedil;", "ç"}, {"&cedil;", "¸"}, {"&cent;", "¢"},
{"&chi;", "χ"}, {"&circ;", "ˆ"}, {"&clubs;", ""}, {"&cong;", ""}, {"&copy;", "©"},
{"&crarr;", ""}, {"&cup;", ""}, {"&curren;", "¤"}, {"&dagger;", ""}, {"&darr;", ""},
{"&deg;", "°"}, {"&delta;", "δ"}, {"&diams;", ""}, {"&divide;", "÷"}, {"&eacute;", "é"},
{"&ecirc;", "ê"}, {"&egrave;", "è"}, {"&empty;", ""}, {"&emsp;", " "}, {"&ensp;", " "},
{"&epsilon;", "ε"}, {"&equiv;", ""}, {"&eta;", "η"}, {"&eth;", "ð"}, {"&euml;", "ë"},
{"&euro;", ""}, {"&exist;", ""}, {"&fnof;", "ƒ"}, {"&forall;", ""}, {"&frac12;", "½"},
{"&frac14;", "¼"}, {"&frac34;", "¾"}, {"&frasl;", ""}, {"&gamma;", "γ"}, {"&ge;", ""},
{"&gt;", ">"}, {"&harr;", ""}, {"&hearts;", ""}, {"&hellip;", ""}, {"&iacute;", "í"},
{"&icirc;", "î"}, {"&iexcl;", "¡"}, {"&igrave;", "ì"}, {"&infin;", ""}, {"&int;", ""},
{"&iota;", "ι"}, {"&iquest;", "¿"}, {"&isin;", ""}, {"&iuml;", "ï"}, {"&kappa;", "κ"},
{"&lambda;", "λ"}, {"&laquo;", "«"}, {"&larr;", ""}, {"&lceil;", ""}, {"&ldquo;", "\u201C"},
{"&le;", ""}, {"&lfloor;", ""}, {"&lowast;", ""}, {"&loz;", ""}, {"&lrm;", "\u200E"},
{"&lsaquo;", ""}, {"&lsquo;", "\u2018"}, {"&lt;", "<"}, {"&macr;", "¯"}, {"&mdash;", ""},
{"&micro;", "µ"}, {"&minus;", ""}, {"&mu;", "μ"}, {"&nabla;", ""}, {"&nbsp;", "\xC2\xA0"},
{"&ndash;", ""}, {"&ne;", ""}, {"&ni;", ""}, {"&not;", "¬"}, {"&notin;", ""},
{"&nsub;", ""}, {"&ntilde;", "ñ"}, {"&nu;", "ν"}, {"&oacute;", "ó"}, {"&ocirc;", "ô"},
{"&oelig;", "œ"}, {"&ograve;", "ò"}, {"&oline;", ""}, {"&omega;", "ω"}, {"&omicron;", "ο"},
{"&oplus;", ""}, {"&or;", ""}, {"&ordf;", "ª"}, {"&ordm;", "º"}, {"&oslash;", "ø"},
{"&otilde;", "õ"}, {"&otimes;", ""}, {"&ouml;", "ö"}, {"&para;", ""}, {"&part;", ""},
{"&permil;", ""}, {"&perp;", ""}, {"&phi;", "φ"}, {"&pi;", "π"}, {"&piv;", "ϖ"},
{"&plusmn;", "±"}, {"&pound;", "£"}, {"&prime;", ""}, {"&prod;", ""}, {"&prop;", ""},
{"&psi;", "ψ"}, {"&quot;", "\""}, {"&radic;", ""}, {"&raquo;", "»"}, {"&rarr;", ""},
{"&rceil;", ""}, {"&rdquo;", "\u201D"}, {"&reg;", "®"}, {"&rfloor;", ""}, {"&rho;", "ρ"},
{"&rlm;", "\u200F"}, {"&rsaquo;", ""}, {"&rsquo;", "\u2019"}, {"&sbquo;", ""}, {"&scaron;", "š"},
{"&sdot;", ""}, {"&sect;", "§"}, {"&shy;", "\xC2\xAD"}, {"&sigma;", "σ"}, {"&sigmaf;", "ς"},
{"&sim;", ""}, {"&spades;", ""}, {"&sub;", ""}, {"&sube;", ""}, {"&sum;", ""},
{"&sup1;", "¹"}, {"&sup2;", "²"}, {"&sup3;", "³"}, {"&sup;", ""}, {"&supe;", ""},
{"&szlig;", "ß"}, {"&tau;", "τ"}, {"&there4;", ""}, {"&theta;", "θ"}, {"&thetasym;", "ϑ"},
{"&thinsp;", " "}, {"&thorn;", "þ"}, {"&tilde;", "˜"}, {"&times;", "×"}, {"&trade;", ""},
{"&uacute;", "ú"}, {"&uarr;", ""}, {"&ucirc;", "û"}, {"&ugrave;", "ù"}, {"&uml;", "¨"},
{"&upsih;", "ϒ"}, {"&upsilon;", "υ"}, {"&uuml;", "ü"}, {"&xi;", "ξ"}, {"&yacute;", "ý"},
{"&yen;", "¥"}, {"&yuml;", "ÿ"}, {"&zeta;", "ζ"}, {"&zwj;", "\u200D"}, {"&zwnj;", "\u200C"},
{"&AElig;", "Æ"}, {"&Aacute;", "Á"}, {"&Acirc;", "Â"}, {"&Agrave;", "À"}, {"&Alpha;", "Α"},
{"&Aring;", "Å"}, {"&Atilde;", "Ã"}, {"&Auml;", "Ä"}, {"&Beta;", "Β"}, {"&Ccedil;", "Ç"},
{"&Chi;", "Χ"}, {"&Dagger;", ""}, {"&Delta;", "Δ"}, {"&ETH;", "Ð"}, {"&Eacute;", "É"},
{"&Ecirc;", "Ê"}, {"&Egrave;", "È"}, {"&Epsilon;", "Ε"}, {"&Eta;", "Η"}, {"&Euml;", "Ë"},
{"&Gamma;", "Γ"}, {"&Iacute;", "Í"}, {"&Icirc;", "Î"}, {"&Igrave;", "Ì"}, {"&Iota;", "Ι"},
{"&Iuml;", "Ï"}, {"&Kappa;", "Κ"}, {"&Lambda;", "Λ"}, {"&Mu;", "Μ"}, {"&Ntilde;", "Ñ"},
{"&Nu;", "Ν"}, {"&OElig;", "Œ"}, {"&Oacute;", "Ó"}, {"&Ocirc;", "Ô"}, {"&Ograve;", "Ò"},
{"&Omega;", "Ω"}, {"&Omicron;", "Ο"}, {"&Oslash;", "Ø"}, {"&Otilde;", "Õ"}, {"&Ouml;", "Ö"},
{"&Phi;", "Φ"}, {"&Pi;", "Π"}, {"&Prime;", ""}, {"&Psi;", "Ψ"}, {"&Rho;", "Ρ"},
{"&Scaron;", "Š"}, {"&Sigma;", "Σ"}, {"&THORN;", "Þ"}, {"&Tau;", "Τ"}, {"&Theta;", "Θ"},
{"&Uacute;", "Ú"}, {"&Ucirc;", "Û"}, {"&Ugrave;", "Ù"}, {"&Upsilon;", "Υ"}, {"&Uuml;", "Ü"},
{"&Xi;", "Ξ"}, {"&Yacute;", "Ý"}, {"&Yuml;", "Ÿ"}, {"&Zeta;", "Ζ"}, {"&aacute;", "á"},
{"&acirc;", "â"}, {"&acute;", "´"}, {"&aelig;", "æ"}, {"&agrave;", "à"}, {"&alefsym;", ""},
{"&alpha;", "α"}, {"&amp;", "&"}, {"&and;", ""}, {"&ang;", ""}, {"&aring;", "å"},
{"&asymp;", ""}, {"&atilde;", "ã"}, {"&auml;", "ä"}, {"&bdquo;", ""}, {"&beta;", "β"},
{"&brvbar;", "¦"}, {"&bull;", ""}, {"&cap;", ""}, {"&ccedil;", "ç"}, {"&cedil;", "¸"},
{"&cent;", "¢"}, {"&chi;", "χ"}, {"&circ;", "ˆ"}, {"&clubs;", ""}, {"&cong;", ""},
{"&copy;", "©"}, {"&crarr;", ""}, {"&cup;", ""}, {"&curren;", "¤"}, {"&dArr;", ""},
{"&dagger;", ""}, {"&darr;", ""}, {"&deg;", "°"}, {"&delta;", "δ"}, {"&diams;", ""},
{"&divide;", "÷"}, {"&eacute;", "é"}, {"&ecirc;", "ê"}, {"&egrave;", "è"}, {"&empty;", ""},
{"&emsp;", " "}, {"&ensp;", " "}, {"&epsilon;", "ε"}, {"&equiv;", ""}, {"&eta;", "η"},
{"&eth;", "ð"}, {"&euml;", "ë"}, {"&euro;", ""}, {"&exist;", ""}, {"&fnof;", "ƒ"},
{"&forall;", ""}, {"&frac12;", "½"}, {"&frac14;", "¼"}, {"&frac34;", "¾"}, {"&frasl;", ""},
{"&gamma;", "γ"}, {"&ge;", ""}, {"&gt;", ">"}, {"&hArr;", ""}, {"&harr;", ""},
{"&hearts;", ""}, {"&hellip;", ""}, {"&iacute;", "í"}, {"&icirc;", "î"}, {"&iexcl;", "¡"},
{"&igrave;", "ì"}, {"&image;", ""}, {"&infin;", ""}, {"&int;", ""}, {"&iota;", "ι"},
{"&iquest;", "¿"}, {"&isin;", ""}, {"&iuml;", "ï"}, {"&kappa;", "κ"}, {"&lArr;", ""},
{"&lambda;", "λ"}, {"&lang;", ""}, {"&laquo;", "«"}, {"&larr;", ""}, {"&lceil;", ""},
{"&ldquo;", "\u201C"}, {"&le;", ""}, {"&lfloor;", ""}, {"&lowast;", ""}, {"&loz;", ""},
{"&lrm;", "\u200E"}, {"&lsaquo;", ""}, {"&lsquo;", "\u2018"}, {"&lt;", "<"}, {"&macr;", "¯"},
{"&mdash;", ""}, {"&micro;", "µ"}, {"&middot;", "·"}, {"&minus;", ""}, {"&mu;", "μ"},
{"&nabla;", ""}, {"&nbsp;", "\xC2\xA0"}, {"&ndash;", ""}, {"&ne;", ""}, {"&ni;", ""},
{"&not;", "¬"}, {"&notin;", ""}, {"&nsub;", ""}, {"&ntilde;", "ñ"}, {"&nu;", "ν"},
{"&oacute;", "ó"}, {"&ocirc;", "ô"}, {"&oelig;", "œ"}, {"&ograve;", "ò"}, {"&oline;", ""},
{"&omega;", "ω"}, {"&omicron;", "ο"}, {"&oplus;", ""}, {"&or;", ""}, {"&ordf;", "ª"},
{"&ordm;", "º"}, {"&oslash;", "ø"}, {"&otilde;", "õ"}, {"&otimes;", ""}, {"&ouml;", "ö"},
{"&para;", ""}, {"&part;", ""}, {"&permil;", ""}, {"&perp;", ""}, {"&phi;", "φ"},
{"&pi;", "π"}, {"&piv;", "ϖ"}, {"&plusmn;", "±"}, {"&pound;", "£"}, {"&prime;", ""},
{"&prod;", ""}, {"&prop;", ""}, {"&psi;", "ψ"}, {"&quot;", "\""}, {"&rArr;", ""},
{"&radic;", ""}, {"&rang;", ""}, {"&raquo;", "»"}, {"&rarr;", ""}, {"&rceil;", ""},
{"&rdquo;", "\u201D"}, {"&real;", "\u211C"}, {"&reg;", "®"}, {"&rfloor;", ""}, {"&rho;", "ρ"},
{"&rlm;", "\u200F"}, {"&rsaquo;", ""}, {"&rsquo;", "\u2019"}, {"&sbquo;", ""}, {"&scaron;", "š"},
{"&sdot;", ""}, {"&sect;", "§"}, {"&shy;", "\xC2\xAD"}, {"&sigma;", "σ"}, {"&sigmaf;", "ς"},
{"&sim;", ""}, {"&spades;", ""}, {"&sub;", ""}, {"&sube;", ""}, {"&sum;", ""},
{"&sup1;", "¹"}, {"&sup2;", "²"}, {"&sup3;", "³"}, {"&sup;", ""}, {"&supe;", ""},
{"&szlig;", "ß"}, {"&tau;", "τ"}, {"&there4;", ""}, {"&theta;", "θ"}, {"&thetasym;", "ϑ"},
{"&thinsp;", " "}, {"&thorn;", "þ"}, {"&tilde;", "˜"}, {"&times;", "×"}, {"&trade;", ""},
{"&uArr;", ""}, {"&uacute;", "ú"}, {"&uarr;", ""}, {"&ucirc;", "û"}, {"&ugrave;", "ù"},
{"&uml;", "¨"}, {"&upsih;", "ϒ"}, {"&upsilon;", "υ"}, {"&uuml;", "ü"}, {"&weierp;", ""},
{"&xi;", "ξ"}, {"&yacute;", "ý"}, {"&yen;", "¥"}, {"&yuml;", "ÿ"}, {"&zeta;", "ζ"},
{"&zwj;", "\u200D"}, {"&zwnj;", "\u200C"},
};
// Verify the table is sorted at compile time.
@@ -317,6 +317,14 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
const bool isTocAnchor =
std::find(self->tocAnchors.begin(), self->tocAnchors.end(), idValue) != self->tocAnchors.end();
if (isTocAnchor || (!isNonNavigableInlineElement(name) && self->anchorData.size() < MAX_ANCHORS_PER_CHAPTER)) {
// Flush a displaced anchor before overwriting. Consecutive non-block elements
// (e.g. <aside id="fn1">text</aside><aside id="fn2">) with no intervening block
// never trigger startNewTextBlock, so fn1 gets silently overwritten. That leaves
// fn1 missing from the anchor map -> getPageForAnchor returns nullopt -> reader
// lands at page 0 (section start) instead of the footnote.
if (!self->pendingAnchorId.empty()) {
self->flushPendingAnchor();
}
self->pendingAnchorId = idValue;
}
} else if (strcmp(atts[i], "dir") == 0) {
+22 -14
View File
@@ -3,6 +3,7 @@
#include <algorithm>
#include <cctype>
#include <cstring>
#include <string_view>
#include <vector>
namespace FsHelpers {
@@ -36,12 +37,14 @@ std::string decodeUriEscapes(const std::string& path) {
}
std::string normalisePath(const std::string& path) {
std::vector<std::string> components;
std::string component;
std::vector<std::string_view> components;
components.reserve(8); // Eight nested folders is more than we might expect
for (const auto c : path) {
if (c == '/') {
if (!component.empty()) {
size_t start = 0;
for (size_t i = 0; i <= path.length(); ++i) {
if (i == path.length() || path[i] == '/') {
if (i > start) {
std::string_view component(path.data() + start, i - start);
if (component == "..") {
if (!components.empty()) {
components.pop_back();
@@ -49,23 +52,28 @@ std::string normalisePath(const std::string& path) {
} else {
components.push_back(component);
}
component.clear();
}
} else {
component += c;
start = i + 1;
}
}
if (!component.empty()) {
components.push_back(component);
if (components.empty()) {
return "";
}
size_t total_len = 0;
for (const auto& c : components) {
total_len += c.length() + 1;
}
std::string result;
for (const auto& c : components) {
if (!result.empty()) {
result += "/";
result.reserve(total_len - 1);
for (size_t i = 0; i < components.size(); ++i) {
if (i > 0) {
result += '/';
}
result += c;
result.append(components[i].data(), components[i].length());
}
return result;
+219 -15
View File
@@ -661,8 +661,10 @@ void GfxRenderer::drawRoundedRect(const int x, const int y, const int width, con
}
void GfxRenderer::fillRect(const int x, const int y, const int width, const int height, const bool state) const {
for (int fillY = y; fillY < y + height; fillY++) {
drawLine(x, fillY, x + width - 1, fillY, state);
if (state) {
fillRectImpl<Color::Black>(x, y, width, height);
} else {
fillRectImpl<Color::White>(x, y, width, height);
}
}
@@ -694,26 +696,194 @@ void GfxRenderer::drawPixelDither<Color::DarkGray>(const int x, const int y) con
}
void GfxRenderer::fillRectDither(const int x, const int y, const int width, const int height, Color color) const {
if (color == Color::Clear) {
} else if (color == Color::Black) {
fillRect(x, y, width, height, true);
} else if (color == Color::White) {
fillRect(x, y, width, height, false);
} else if (color == Color::LightGray) {
for (int fillY = y; fillY < y + height; fillY++) {
for (int fillX = x; fillX < x + width; fillX++) {
drawPixelDither<Color::LightGray>(fillX, fillY);
switch (color) {
case Color::Clear:
break;
case Color::Black:
fillRectImpl<Color::Black>(x, y, width, height);
break;
case Color::White:
fillRectImpl<Color::White>(x, y, width, height);
break;
case Color::LightGray:
fillRectImpl<Color::LightGray>(x, y, width, height);
break;
case Color::DarkGray:
fillRectImpl<Color::DarkGray>(x, y, width, height);
break;
}
}
template <Color C>
void GfxRenderer::fillRectImpl(const int x, const int y, const int width, const int height) const {
if constexpr (C == Color::Clear) return;
if (width <= 0 || height <= 0) return;
if (fontCacheManager_ && fontCacheManager_->isScanning()) return;
// Clip in logical space.
const int screenW = getScreenWidth();
const int screenH = getScreenHeight();
const int lx0 = std::max(0, x);
const int ly0 = std::max(0, y);
const int lx1 = std::min(screenW, x + width);
const int ly1 = std::min(screenH, y + height);
if (lx0 >= lx1 || ly0 >= ly1) return;
// Rotate the two opposing logical corners into physical-framebuffer space.
// The bounding rect in physical space is the rect we need to fill — rotation
// is rigid (no shear/stretch) so the bbox of the two corners IS the rect.
int paX, paY, pbX, pbY;
rotateCoordinates(orientation, lx0, ly0, &paX, &paY, panelWidth, panelHeight);
rotateCoordinates(orientation, lx1 - 1, ly1 - 1, &pbX, &pbY, panelWidth, panelHeight);
const int phyX0 = std::min(paX, pbX);
const int phyX1 = std::max(paX, pbX); // inclusive
int phyY0 = std::min(paY, pbY);
int phyY1 = std::max(paY, pbY);
// Strip mode: clip Y range to the active band and redirect writes.
uint8_t* target = getWriteTarget();
const int originY = getWriteOriginY();
const int writeRows = getWriteRows();
phyY0 = std::max(phyY0, originY);
phyY1 = std::min(phyY1, originY + writeRows - 1);
if (phyY0 > phyY1) return;
// Bit/byte layout: MSB-first within a byte, so phyX → bit (7 - (phyX & 7)).
// Head and tail masks cover only the in-rect bits of the first/last byte.
const int byteStart = phyX0 >> 3;
const int byteEnd = phyX1 >> 3; // inclusive
const uint8_t headMask = static_cast<uint8_t>(0xFFu >> (phyX0 & 7));
const uint8_t tailMask = static_cast<uint8_t>(0xFFu << (7 - (phyX1 & 7)));
const int32_t panelStride = static_cast<int32_t>(panelWidthBytes);
if constexpr (C == Color::Black || C == Color::White) {
// Solid fill. Framebuffer: 0 = black, 1 = white.
const uint8_t fillByte = (C == Color::Black) ? 0x00u : 0xFFu;
for (int py = phyY0; py <= phyY1; ++py) {
uint8_t* row = target + static_cast<int32_t>(py - originY) * panelStride;
if (byteStart == byteEnd) {
const uint8_t mask = headMask & tailMask;
if constexpr (C == Color::Black) {
row[byteStart] &= static_cast<uint8_t>(~mask);
} else {
row[byteStart] |= mask;
}
} else {
if constexpr (C == Color::Black) {
row[byteStart] &= static_cast<uint8_t>(~headMask);
if (byteEnd > byteStart + 1) {
memset(row + byteStart + 1, fillByte, byteEnd - byteStart - 1);
}
row[byteEnd] &= static_cast<uint8_t>(~tailMask);
} else {
row[byteStart] |= headMask;
if (byteEnd > byteStart + 1) {
memset(row + byteStart + 1, fillByte, byteEnd - byteStart - 1);
}
row[byteEnd] |= tailMask;
}
}
}
} else if (color == Color::DarkGray) {
for (int fillY = y; fillY < y + height; fillY++) {
for (int fillX = x; fillX < x + width; fillX++) {
drawPixelDither<Color::DarkGray>(fillX, fillY);
} else {
// Dither (LightGray / DarkGray). Both patterns have period 2 in logical
// (x, y), so per physical row we precompute one byte that represents the
// pattern across an 8-pixel stretch — every full byte in the row uses
// that same value.
//
// dlxPerPhyX / dlyPerPhyX: how logical (x, y) change as phyX increments
// along a physical row. Derived from inverting rotateCoordinates.
int dlxPerPhyX = 0, dlyPerPhyX = 0;
switch (orientation) {
case Portrait:
dlxPerPhyX = 0;
dlyPerPhyX = 1;
break;
case PortraitInverted:
dlxPerPhyX = 0;
dlyPerPhyX = -1;
break;
case LandscapeClockwise:
dlxPerPhyX = -1;
dlyPerPhyX = 0;
break;
case LandscapeCounterClockwise:
dlxPerPhyX = 1;
dlyPerPhyX = 0;
break;
}
// The dither pattern has period 2 in logical space, and each orientation
// maps py to logical coords with a fixed parity relationship. The
// blackMask byte therefore repeats with period 2 in py. Precompute both
// variants outside the row loop to eliminate the per-row switch + 8-bit
// construction loop.
uint8_t blackMasks[2];
for (int parityIdx = 0; parityIdx < 2; ++parityIdx) {
const int samplePy = phyY0 + parityIdx;
int lxBase = 0, lyBase = 0;
switch (orientation) {
case Portrait:
lxBase = panelHeight - 1 - samplePy;
lyBase = byteStart * 8;
break;
case PortraitInverted:
lxBase = samplePy;
lyBase = panelWidth - 1 - byteStart * 8;
break;
case LandscapeClockwise:
lxBase = panelWidth - 1 - byteStart * 8;
lyBase = panelHeight - 1 - samplePy;
break;
case LandscapeCounterClockwise:
lxBase = byteStart * 8;
lyBase = samplePy;
break;
}
uint8_t mask = 0;
for (int b = 0; b < 8; ++b) {
const int lx = lxBase + b * dlxPerPhyX;
const int ly = lyBase + b * dlyPerPhyX;
bool isBlack;
if constexpr (C == Color::LightGray) {
isBlack = ((lx & 1) == 0) && ((ly & 1) == 0);
} else { // DarkGray
isBlack = (((lx + ly) & 1) == 0);
}
if (isBlack) mask |= static_cast<uint8_t>(1u << (7 - b));
}
blackMasks[samplePy & 1] = mask;
}
for (int py = phyY0; py <= phyY1; ++py) {
const uint8_t blackMask = blackMasks[py & 1];
const uint8_t whiteMask = static_cast<uint8_t>(~blackMask);
// Dither writes BOTH inks (the slow path called drawPixel for every
// pixel — setting or clearing — so we must do the same). Inside the
// rect mask: write whiteMask (1s where white, 0s where black). Outside
// the rect mask: leave the framebuffer untouched.
uint8_t* row = target + static_cast<int32_t>(py - originY) * panelStride;
if (byteStart == byteEnd) {
const uint8_t rectMask = headMask & tailMask;
row[byteStart] = static_cast<uint8_t>((row[byteStart] & ~rectMask) | (rectMask & whiteMask));
} else {
row[byteStart] = static_cast<uint8_t>((row[byteStart] & ~headMask) | (headMask & whiteMask));
if (byteEnd > byteStart + 1) {
// Period 2, so every full byte in this row is exactly whiteMask.
memset(row + byteStart + 1, whiteMask, byteEnd - byteStart - 1);
}
row[byteEnd] = static_cast<uint8_t>((row[byteEnd] & ~tailMask) | (tailMask & whiteMask));
}
}
}
}
template void GfxRenderer::fillRectImpl<Color::Black>(int, int, int, int) const;
template void GfxRenderer::fillRectImpl<Color::White>(int, int, int, int) const;
template void GfxRenderer::fillRectImpl<Color::LightGray>(int, int, int, int) const;
template void GfxRenderer::fillRectImpl<Color::DarkGray>(int, int, int, int) const;
void GfxRenderer::maskRoundedRectOutsideCorners(const int x, const int y, const int width, const int height,
const int radius, const Color color) const {
if (radius <= 0 || color == Color::Clear) {
@@ -1437,8 +1607,18 @@ int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, EpdFontFami
int32_t widthFP = 0;
const bool isSupSub = (style & (EpdFontFamily::SUP | EpdFontFamily::SUB)) != 0;
const uint8_t styleIdx = resolveSdCardStyle(*sdIt->second, style);
const auto fontIt = fontMap.find(fontId);
if (fontIt == fontMap.end()) {
LOG_ERR("GFX", "Font %d not found", fontId);
return 0;
}
const auto& font = fontIt->second;
while (uint32_t cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text))) {
int32_t advFP = sdIt->second->getAdvance(cp, styleIdx);
if (advFP == 0 && !utf8IsCombiningMark(cp)) {
const EpdGlyph* glyph = font.getGlyph(cp, style);
advFP = glyph ? glyph->advanceX : 0;
}
widthFP += isSupSub ? (advFP + 1) / 2 : advFP;
}
return fp4::toPixel(widthFP);
@@ -1577,6 +1757,30 @@ size_t GfxRenderer::getBufferSize() const { return frameBufferSize; }
// unused
// void GfxRenderer::grayscaleRevert() const { display.grayscaleRevert(); }
void GfxRenderer::displayGrayscaleBase(HalDisplay::RefreshMode fallback) const {
display.displayGrayscaleBase(fallback, fadingFix);
}
void GfxRenderer::preconditionGrayscale() const { display.preconditionGrayscale(); }
void GfxRenderer::preconditionGrayscale(int x, int y, int w, int h) const {
if (w <= 0 || h <= 0) return;
// Rotate the logical rect's opposite corners to physical panel coords; the
// physical bbox stays axis-aligned for all four orientations.
int ax, ay, bx, by;
rotateCoordinates(orientation, x, y, &ax, &ay, panelWidth, panelHeight);
rotateCoordinates(orientation, x + w - 1, y + h - 1, &bx, &by, panelWidth, panelHeight);
int x0 = ax < bx ? ax : bx, x1 = ax > bx ? ax : bx;
int y0 = ay < by ? ay : by, y1 = ay > by ? ay : by;
if (x0 < 0) x0 = 0;
if (y0 < 0) y0 = 0;
if (x1 >= panelWidth) x1 = panelWidth - 1;
if (y1 >= panelHeight) y1 = panelHeight - 1;
if (x1 < x0 || y1 < y0) return;
display.preconditionGrayscale(static_cast<uint16_t>(x0), static_cast<uint16_t>(y0),
static_cast<uint16_t>(x1 - x0 + 1), static_cast<uint16_t>(y1 - y0 + 1));
}
void GfxRenderer::copyGrayscaleLsbBuffers() const { display.copyGrayscaleLsbBuffers(frameBuffer); }
void GfxRenderer::copyGrayscaleMsbBuffers() const { display.copyGrayscaleMsbBuffers(frameBuffer); }
+16
View File
@@ -81,6 +81,12 @@ class GfxRenderer {
void drawPixelDither(int x, int y) const;
template <Color color>
void fillArc(int maxRadius, int cx, int cy, int xDir, int yDir) const;
// Byte-aligned, orientation-specialized rectangle fill. Rotates the rect's
// two opposing corners into physical-framebuffer space once, then walks each
// physical row with head-mask / middle memset / tail-mask byte writes — no
// per-pixel rotation, no per-pixel RMW.
template <Color color>
void fillRectImpl(int x, int y, int width, int height) const;
public:
explicit GfxRenderer(HalDisplay& halDisplay)
@@ -218,6 +224,16 @@ class GfxRenderer {
// Grayscale functions
void setRenderMode(const RenderMode mode) { this->renderMode = mode; }
RenderMode getRenderMode() const { return renderMode; }
// Grayscale preconditioning settle pass (no-op on X4). The rect overload
// takes the gray region in LOGICAL screen coordinates and rotates it to the
// panel; the no-arg overload settles the full frame. Call after the BW base
// frame is displayed and before the grayscale planes are written.
void preconditionGrayscale() const;
void preconditionGrayscale(int x, int y, int w, int h) const;
// Display the framebuffer as the base frame for a grayscale overlay that
// follows (X3: OEM differential base waveform; others: plain display with
// `fallback`).
void displayGrayscaleBase(HalDisplay::RefreshMode fallback = HalDisplay::HALF_REFRESH) const;
void copyGrayscaleLsbBuffers() const;
void copyGrayscaleMsbBuffers() const;
void displayGrayBuffer() const;
+5
View File
@@ -70,6 +70,7 @@ STR_ORIENTATION: "Арыентацыя чытання"
STR_SIDE_BTN_LAYOUT: "Бакавыя кнопкі"
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Арыентаваць пярэднія кнопкі"
STR_LONG_PRESS_SKIP: "Доўгае націсканне - змена раздзела"
STR_FONT_PREVIEW_TEXT: "У Іўі худы жвавы чорт у зялёнай камізэльцы пабег пад'есці фаршу з юшкай"
STR_FONT_FAMILY: "Шрыфт чытання"
STR_FONT_SIZE: "Памер шрыфту інтэрфейсу"
STR_LINE_SPACING: "Міжрадковы інтэрвал"
@@ -125,6 +126,7 @@ STR_PAGE_TURN: "Перагортванне"
STR_PORTRAIT: "Партрэт"
STR_LANDSCAPE_CW: "Ландшафт (CW)"
STR_INVERTED: "Інверсія"
STR_ORIENTATION_INVERTED: "Партрэт 180°"
STR_LANDSCAPE_CCW: "Ландшафт (CCW)"
STR_PREV_NEXT: "Назад/Наперад"
STR_NEXT_PREV: "Наперад/Назад"
@@ -175,6 +177,9 @@ STR_EXIT: "« Выхад"
STR_HOME: "« Галоўная"
STR_SELECT: "Абраць"
STR_TOGGLE: "Выбар"
STR_TOGGLE_BOOKMARK: "Пераключыць закладку"
STR_BOOKMARK_REMOVED: "Закладка выдалена."
STR_HOLD_OPEN_TO_DELETE: "Утрымлівайце Адкрыць, каб выдаліць"
STR_CONFIRM: "Пацв."
STR_CANCEL: "Адмена"
STR_CONNECT: "Падкл."
+9 -2
View File
@@ -77,6 +77,7 @@ STR_LONG_PRESS_BEHAVIOR: "Comportament de prémer llargament el botó"
STR_LONG_PRESS_BEHAVIOR_OFF: "Desactivat"
STR_LONG_PRESS_BEHAVIOR_SKIP: "Saltar capítols"
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Canvi d'orientació"
STR_LONG_PRESS_MENU: "Funció de pulsació llarga"
STR_FONT_FAMILY: "Tipus de lletra"
STR_FONT_SIZE: "Mida de la lletra (UI)"
STR_LINE_SPACING: "Interlineat del lector"
@@ -135,9 +136,12 @@ STR_PAGE_TURN: "Canvi de pàgina"
STR_PORTRAIT: "Vertical"
STR_LANDSCAPE_CW: "Horitzontal horari"
STR_INVERTED: "Invertit"
STR_ORIENTATION_INVERTED: "Vertical 180°"
STR_LANDSCAPE_CCW: "Horitzontal antihorari"
STR_PREV_NEXT: "Anterior/Següent"
STR_NEXT_PREV: "Següent/Anterior"
STR_KOSYNC: "KOSync"
STR_BOOKMARK_OPTION: "Punt de llibre"
STR_DISABLED: "Desactivats"
STR_NOTO_SERIF: "Noto Serif"
STR_NOTO_SANS: "Noto Sans"
@@ -172,8 +176,7 @@ STR_DOWNLOADING: "S'està baixant..."
STR_DOWNLOAD_FAILED: "Ha fallat la baixada"
STR_ERROR_MSG: "Error:"
STR_UNNAMED: "Sense nom"
STR_HOLD_CONFIRM_TO_DELETE: "Manteniu premut Confirma per esborrar"
STR_BOOKMARK_INSTRUCTIONS: "Manteniu premut Confirma al lector per crear un punt de llibre."
STR_HOLD_OPEN_TO_DELETE: "Manteniu premut Obre per esborrar"
STR_NO_SERVER_URL: "No s'ha configurat cap URL de servidor"
STR_FETCH_FEED_FAILED: "Ha fallat l'obtenció del feed"
STR_PARSE_FEED_FAILED: "Ha fallat l'anàlisi del feed"
@@ -189,6 +192,7 @@ STR_HOME: "« Inici"
STR_SELECT: "Selecciona"
STR_SELECTED: "Seleccionat"
STR_TOGGLE: "Canvia"
STR_TOGGLE_BOOKMARK: "Commuta punt de llibre"
STR_CONFIRM: "Confirma"
STR_CANCEL: "Cancel·la"
STR_CONNECT: "Connecta"
@@ -233,6 +237,7 @@ STR_QUICK_RESUME_TIMEOUT: "Represa ràpida després del temps"
STR_REMAP_FRONT_BUTTONS: "Reassigna els botons frontals"
STR_BOOKMARKS: "Punts de llibre"
STR_BOOKMARK_ADDED: "S'ha afegit el punt de llibre."
STR_BOOKMARK_REMOVED: "S'ha eliminat el punt de llibre."
STR_OPDS_BROWSER: "Navegador OPDS"
STR_COVER_CUSTOM: "Portada + Personalitzat"
STR_QUICK_RESUME: "Represa ràpida"
@@ -294,6 +299,7 @@ STR_BOOK_S_STYLE: "Estil del llibre"
STR_EMBEDDED_STYLE: "Estil incrustat"
STR_FOCUS_READING: "Lectura enfocada"
STR_OPDS_SERVER_URL: "URL del servidor OPDS"
STR_PWR_BTN_FOOTNOTE_BACK: "Retorn ràpid des de les notes al peu"
STR_FOOTNOTES: "Notes al peu"
STR_NO_FOOTNOTES: "No hi ha notes al peu en aquesta pàgina"
STR_LINK: "[enllaç]"
@@ -338,6 +344,7 @@ STR_MANAGE_FONTS: "Gestiona els tipus de lletra"
STR_FONT_BROWSER: "Navegador de tipus de lletra"
STR_LOADING_FONT_LIST: "S'està carregant la llista de tipus de lletra..."
STR_NO_FONTS_AVAILABLE: "No hi ha tipus de lletra disponibles"
STR_FONT_PREVIEW_TEXT: "Jove xef, porti whisky amb quinze glaçons d'hidrogen, coi!"
STR_FONT_INSTALLED: "Tipus de lletra instal·lat!"
STR_FONT_INSTALL_FAILED: "Ha fallat la instal·lació del tipus de lletra"
STR_INSTALLED: "Instal·lat"
+5
View File
@@ -73,6 +73,7 @@ STR_LONG_PRESS_BEHAVIOR: "Chování při dlouhém stisknutí tlačítka"
STR_LONG_PRESS_BEHAVIOR_OFF: "VYP"
STR_LONG_PRESS_BEHAVIOR_SKIP: "Přeskočení kapitoly"
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Změna orientace"
STR_FONT_PREVIEW_TEXT: "Příliš žluťoučký kůň úpěl ďábelské ódy"
STR_FONT_FAMILY: "Rodina písem čtečky"
STR_FONT_SIZE: "Velikost písma rozhraní"
STR_LINE_SPACING: "Řádkování čtečky"
@@ -130,6 +131,7 @@ STR_PAGE_TURN: "Otáčení stránek"
STR_PORTRAIT: "Na výšku"
STR_LANDSCAPE_CW: "Na šířku po směru hod. ručiček"
STR_INVERTED: "Invertovaný"
STR_ORIENTATION_INVERTED: "Na výšku 180°"
STR_LANDSCAPE_CCW: "Na šířku proti směru hod. ručiček"
STR_PREV_NEXT: "Předchozí/Další"
STR_NEXT_PREV: "Další/Předchozí"
@@ -180,6 +182,9 @@ STR_EXIT: "« Konec"
STR_HOME: "« Domů"
STR_SELECT: "Vybrat"
STR_TOGGLE: "Přepnout"
STR_TOGGLE_BOOKMARK: "Přepnout záložku"
STR_BOOKMARK_REMOVED: "Záložka odstraněna."
STR_HOLD_OPEN_TO_DELETE: "Podržte Otevřít pro smazání"
STR_CONFIRM: "Potvrdit"
STR_CANCEL: "Zrušit"
STR_CONNECT: "Připojit"
+5
View File
@@ -77,6 +77,7 @@ STR_LONG_PRESS_BEHAVIOR: "Comportamiento al mantener pulsado el botón"
STR_LONG_PRESS_BEHAVIOR_OFF: "Desactivado"
STR_LONG_PRESS_BEHAVIOR_SKIP: "Saltar capítulo"
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Cambio de orientación"
STR_FONT_PREVIEW_TEXT: "Høj bly gom vandt fræk sexquiz på wc"
STR_FONT_FAMILY: "Læser skrifttype"
STR_FONT_SIZE: "Læser skriftstørrelse"
STR_LINE_SPACING: "Linjeafstand"
@@ -135,6 +136,7 @@ STR_PAGE_TURN: "Sideskift"
STR_PORTRAIT: "Portræt"
STR_LANDSCAPE_CW: "Liggende med uret"
STR_INVERTED: "Inverteret"
STR_ORIENTATION_INVERTED: "Portræt 180°"
STR_LANDSCAPE_CCW: "Liggende mod uret"
STR_PREV_NEXT: "Forrige/Næste"
STR_NEXT_PREV: "Næste/Forrige"
@@ -186,6 +188,9 @@ STR_HOME: "« Hjem"
STR_SELECT: "Vælg"
STR_SELECTED: "Valgt"
STR_TOGGLE: "Skift"
STR_TOGGLE_BOOKMARK: "Skift bogmærke"
STR_BOOKMARK_REMOVED: "Bogmærke fjernet."
STR_HOLD_OPEN_TO_DELETE: "Hold Åbn nede for at slette"
STR_CONFIRM: "Bekræft"
STR_CANCEL: "Annuller"
STR_CONNECT: "Forbind"
+6 -1
View File
@@ -77,6 +77,7 @@ STR_LONG_PRESS_BEHAVIOR: "Long-press button behavior"
STR_LONG_PRESS_BEHAVIOR_OFF: "OFF"
STR_LONG_PRESS_BEHAVIOR_SKIP: "Chapter skip"
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Orientation change"
STR_FONT_PREVIEW_TEXT: "Pa's wijze lynx bezag vroom het fikse aquaduct"
STR_FONT_FAMILY: "Lettertype lezer"
STR_FONT_SIZE: "Lettergrootte lezer"
STR_LINE_SPACING: "Regelafstand lezer"
@@ -134,7 +135,8 @@ STR_SLEEP: "Slaap"
STR_PAGE_TURN: "Pagina omslaan"
STR_PORTRAIT: "Staand"
STR_LANDSCAPE_CW: "Liggend (rechtsom)"
STR_INVERTED: "Omgekeerd"
STR_INVERTED: "Geïnverteerd"
STR_ORIENTATION_INVERTED: "Staand 180°"
STR_LANDSCAPE_CCW: "Liggend (linksom)"
STR_PREV_NEXT: "Vorige/Volgende"
STR_NEXT_PREV: "Volgende/Vorige"
@@ -186,6 +188,9 @@ STR_HOME: "« Home"
STR_SELECT: "Kies"
STR_SELECTED: "Geselecteerd"
STR_TOGGLE: "Wissel"
STR_TOGGLE_BOOKMARK: "Bladwijzer wisselen"
STR_BOOKMARK_REMOVED: "Bladwijzer verwijderd."
STR_HOLD_OPEN_TO_DELETE: "Houd Openen ingedrukt om te verwijderen"
STR_CONFIRM: "Bevestig"
STR_CANCEL: "Annuleer"
STR_CONNECT: "Verbind"
+8 -2
View File
@@ -78,6 +78,8 @@ STR_LONG_PRESS_BEHAVIOR: "Long-press button behavior"
STR_LONG_PRESS_BEHAVIOR_OFF: "OFF"
STR_LONG_PRESS_BEHAVIOR_SKIP: "Chapter skip"
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Orientation change"
STR_LONG_PRESS_MENU: "Long-press Menu"
STR_FONT_PREVIEW_TEXT: "The quick brown fox jumps over the lazy dog"
STR_FONT_FAMILY: "Reader Font Family"
STR_FONT_SIZE: "Reader Font Size"
STR_LINE_SPACING: "Reader Line Spacing"
@@ -137,9 +139,12 @@ STR_FORCE_REFRESH: "Refresh Screen"
STR_PORTRAIT: "Portrait"
STR_LANDSCAPE_CW: "Landscape CW"
STR_INVERTED: "Inverted"
STR_ORIENTATION_INVERTED: "Portrait 180°"
STR_LANDSCAPE_CCW: "Landscape CCW"
STR_PREV_NEXT: "Prev/Next"
STR_NEXT_PREV: "Next/Prev"
STR_KOSYNC: "KOSync"
STR_BOOKMARK_OPTION: "Bookmark"
STR_DISABLED: "Disabled"
STR_NOTO_SERIF: "Noto Serif"
STR_NOTO_SANS: "Noto Sans"
@@ -175,8 +180,7 @@ STR_DOWNLOADING: "Downloading..."
STR_DOWNLOAD_FAILED: "Download failed"
STR_ERROR_MSG: "Error:"
STR_UNNAMED: "Unnamed"
STR_HOLD_CONFIRM_TO_DELETE: "Hold Confirm to Delete"
STR_BOOKMARK_INSTRUCTIONS: "Hold Confirm from the reader to create a bookmark."
STR_HOLD_OPEN_TO_DELETE: "Hold Open to Delete"
STR_NO_SERVER_URL: "No server URL configured"
STR_FETCH_FEED_FAILED: "Failed to fetch feed"
STR_PARSE_FEED_FAILED: "Failed to parse feed"
@@ -194,6 +198,7 @@ STR_HOME: "« Home"
STR_SELECT: "Select"
STR_SELECTED: "Selected"
STR_TOGGLE: "Toggle"
STR_TOGGLE_BOOKMARK: "Toggle Bookmark"
STR_CONFIRM: "Confirm"
STR_CANCEL: "Cancel"
STR_CONNECT: "Connect"
@@ -256,6 +261,7 @@ STR_SUNLIGHT_FADING_FIX: "Sunlight Fading Fix"
STR_REMAP_FRONT_BUTTONS: "Remap Front Buttons"
STR_BOOKMARKS: "Bookmarks"
STR_BOOKMARK_ADDED: "Bookmark added."
STR_BOOKMARK_REMOVED: "Bookmark removed."
STR_OPDS_BROWSER: "OPDS Browser"
STR_SEARCH: "Search"
STR_COVER_CUSTOM: "Cover + Custom"
+6 -1
View File
@@ -73,6 +73,7 @@ STR_LONG_PRESS_BEHAVIOR: "Long-press button behavior"
STR_LONG_PRESS_BEHAVIOR_OFF: "OFF"
STR_LONG_PRESS_BEHAVIOR_SKIP: "Chapter skip"
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Orientation change"
STR_FONT_PREVIEW_TEXT: "Törkylempijävongahdus"
STR_FONT_FAMILY: "Lukijan fonttiperhe"
STR_FONT_SIZE: "Käyttöliittymän fonttikoko"
STR_LINE_SPACING: "Lukijan riviväli"
@@ -129,7 +130,8 @@ STR_SLEEP: "Lepotila"
STR_PAGE_TURN: "Sivunkääntö"
STR_PORTRAIT: "Pysty"
STR_LANDSCAPE_CW: "Vaaka myötäpäivään"
STR_INVERTED: "Käännetty"
STR_INVERTED: "Käänteinen"
STR_ORIENTATION_INVERTED: "Pysty 180°"
STR_LANDSCAPE_CCW: "Vaaka vastapäivään"
STR_PREV_NEXT: "Edell/Seur"
STR_NEXT_PREV: "Seur/Edell"
@@ -180,6 +182,9 @@ STR_EXIT: "« Poistu"
STR_HOME: "« Koti"
STR_SELECT: "Valitse"
STR_TOGGLE: "Vaihda"
STR_TOGGLE_BOOKMARK: "Vaihda kirjanmerkki"
STR_BOOKMARK_REMOVED: "Kirjanmerkki poistettu."
STR_HOLD_OPEN_TO_DELETE: "Pidä Avaa painettuna poistaaksesi"
STR_CONFIRM: "Vahvista"
STR_CANCEL: "Peruuta"
STR_CONNECT: "Yhdistä"
+5
View File
@@ -77,6 +77,7 @@ STR_LONG_PRESS_BEHAVIOR: "Comportement lors d'un appui long"
STR_LONG_PRESS_BEHAVIOR_OFF: "Désactivé"
STR_LONG_PRESS_BEHAVIOR_SKIP: "Saut de chapitre"
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Changement d'orientation"
STR_FONT_PREVIEW_TEXT: "Portez ce vieux whisky au juge blond qui fume"
STR_FONT_FAMILY: "Police de caractères du lecteur"
STR_FONT_SIZE: "Taille police lecteur"
STR_LINE_SPACING: "Interligne"
@@ -135,6 +136,7 @@ STR_PAGE_TURN: "Page suivante"
STR_PORTRAIT: "Portrait"
STR_LANDSCAPE_CW: "Paysage"
STR_INVERTED: "Inversé"
STR_ORIENTATION_INVERTED: "Portrait 180°"
STR_LANDSCAPE_CCW: "Paysage inversé"
STR_PREV_NEXT: "Préc/Suiv"
STR_NEXT_PREV: "Suiv/Préc"
@@ -186,6 +188,9 @@ STR_HOME: "« Accueil"
STR_SELECT: "OK"
STR_SELECTED: "Sélectionné"
STR_TOGGLE: "Modifier"
STR_TOGGLE_BOOKMARK: "Basculer le marque-page"
STR_BOOKMARK_REMOVED: "Marque-page supprimé."
STR_HOLD_OPEN_TO_DELETE: "Maintenir Ouvrir pour supprimer"
STR_CONFIRM: "Confirmer"
STR_CANCEL: "Annuler"
STR_CONNECT: "Connecter"
+9 -5
View File
@@ -70,16 +70,18 @@ STR_ORIENTATION: "Leseausrichtung"
STR_SIDE_BTN_LAYOUT: "Seitliche Tasten (Lesen)"
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Vordere Tasten ausrichten"
STR_LONG_PRESS_BEHAVIOR: "Verhalten bei langem Tastendruck"
STR_LONG_PRESS_BEHAVIOR_OFF: "AUS"
STR_LONG_PRESS_BEHAVIOR_OFF: "Aus"
STR_LONG_PRESS_BEHAVIOR_SKIP: "Kapitel überspringen"
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Ausrichtung ändern"
STR_FONT_PREVIEW_TEXT: "Victor jagt zwölf Boxkämpfer quer über den großen Sylter Deich"
STR_FONT_FAMILY: "Lese-Schriftfamilie"
STR_FONT_SIZE: "Schriftgröße"
STR_LINE_SPACING: "Lese-Zeilenabstand"
STR_SCREEN_MARGIN: "Lese-Seitenränder"
STR_PARA_ALIGNMENT: "Lese-Absatzausrichtung"
STR_LONG_PRESS_MENU: "Menütaste lang drücken"
STR_HYPHENATION: "Silbentrennung"
STR_TIME_TO_SLEEP: "Standby nach"
STR_TIME_TO_SLEEP: "Standby-Modus nach"
STR_REFRESH_FREQ: "Anti-Ghosting nach"
STR_KOREADER_SYNC: "KOReader-Synchr."
STR_CHECK_UPDATES: "Nach Updates suchen"
@@ -128,7 +130,8 @@ STR_PAGE_TURN: "Umblättern"
STR_FORCE_REFRESH: "Bildschirm regenerieren"
STR_PORTRAIT: "Hochformat"
STR_LANDSCAPE_CW: "Querformat rechts"
STR_INVERTED: "Hochformat 180°"
STR_INVERTED: "Invertiert"
STR_ORIENTATION_INVERTED: "Hochformat 180°"
STR_LANDSCAPE_CCW: "Querformat links"
STR_PREV_NEXT: "Zurück/Weiter"
STR_NEXT_PREV: "Weiter/Zurück"
@@ -167,8 +170,7 @@ STR_DOWNLOADING: "Herunterladen…"
STR_DOWNLOAD_FAILED: "Ladefehler"
STR_ERROR_MSG: "Fehler:"
STR_UNNAMED: "Unbenannt"
STR_HOLD_CONFIRM_TO_DELETE: "Halte Bestätigen zum Löschen"
STR_BOOKMARK_INSTRUCTIONS: "Halte Bestätigen im Lesemodus um ein Lesezeichen anzulegen."
STR_HOLD_OPEN_TO_DELETE: "Halte Öffnen zum Löschen"
STR_NO_SERVER_URL: "Keine Server-URL konfiguriert"
STR_FETCH_FEED_FAILED: "Feedfehler"
STR_PARSE_FEED_FAILED: "Feed-Format ungültig"
@@ -186,6 +188,7 @@ STR_HOME: "« Start"
STR_SELECT: "Auswahl"
STR_SELECTED: "Ausgewählt"
STR_TOGGLE: "Ändern"
STR_TOGGLE_BOOKMARK: "Lesezeichen umschalten"
STR_CONFIRM: "Bestätigen"
STR_CANCEL: "Abbrechen"
STR_CONNECT: "Verbinden"
@@ -249,6 +252,7 @@ STR_QUICK_RESUME_TIMEOUT: "Schnelles Fortsetzen nach Timeout"
STR_REMAP_FRONT_BUTTONS: "Vordere Tasten belegen"
STR_BOOKMARKS: "Lesezeichen"
STR_BOOKMARK_ADDED: "Lesezeichen hinzugefügt."
STR_BOOKMARK_REMOVED: "Lesezeichen entfernt."
STR_SEARCH: "Suche"
STR_OPDS_BROWSER: "OPDS-Browser"
STR_COVER_CUSTOM: "Cover + Eigenes"
+13 -3
View File
@@ -77,6 +77,7 @@ STR_LONG_PRESS_BEHAVIOR: "פעולת לחיצה ארוכה"
STR_LONG_PRESS_BEHAVIOR_OFF: "כבוי"
STR_LONG_PRESS_BEHAVIOR_SKIP: "דלג פרק"
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "שנה כיוון מסך"
STR_FONT_PREVIEW_TEXT: "דג סקרן שט בים מאוכזב ולפתע מצא חברה"
STR_FONT_FAMILY: "גופן הקריאה"
STR_FONT_SIZE: "גודל גופן"
STR_LINE_SPACING: "מרווח בין שורות"
@@ -133,7 +134,8 @@ STR_PAGE_TURN: "העברת דף"
STR_FORCE_REFRESH: "רענון מסך מלא"
STR_PORTRAIT: "לאורך"
STR_LANDSCAPE_CW: "לרוחב (ימינה)"
STR_INVERTED: "הפוך"
STR_INVERTED: יפוך צבעים"
STR_ORIENTATION_INVERTED: "לאורך 180°"
STR_LANDSCAPE_CCW: "לרוחב (שמאלה)"
STR_PREV_NEXT: "הקודם/הבא"
STR_NEXT_PREV: "הבא/הקודם"
@@ -188,6 +190,7 @@ STR_HOME: "מסך הבית »"
STR_SELECT: "בחר"
STR_SELECTED: "נבחר"
STR_TOGGLE: "בחר"
STR_TOGGLE_BOOKMARK: "הוסף/הסר סימנייה"
STR_CONFIRM: "אישור"
STR_CANCEL: "ביטול"
STR_CONNECT: "התחבר"
@@ -359,8 +362,7 @@ STR_FRONT_BTN_FOLLOW_ORIENTATION: "התאמת לחצנים קדמיים לכיו
STR_REMOVE_READ_FROM_RECENTS: "הסר ספרים שנקראו מרשימת האחרונים"
STR_MOVE_FINISHED_TO_READ: "העבר ספרים שהסתיימו לתיקיית 'נקראו'"
STR_DISABLED: "מבוטל"
STR_HOLD_CONFIRM_TO_DELETE: "החזק לחוץ על אישור כדי למחוק"
STR_BOOKMARK_INSTRUCTIONS: "החזק לחוץ על כפתור אישור בזמן הקריאה כדי להוסיף סימנייה"
STR_HOLD_OPEN_TO_DELETE: "לחיצה ארוכה על 'פתח' כדי למחוק"
STR_CLOCK: "שעון"
STR_CLOCK_UTC_OFFSET: "הפרש זמן UTC"
STR_CLOCK_FORMAT: "תבנית השעון"
@@ -378,6 +380,14 @@ STR_CLOCK_SYNC_NO_WIFI_HINT: "התחבר תחילה לרשת אלחוטית, ו
STR_CLOCK_SYNCED: "השעון סונכרן"
STR_BOOKMARKS: "סימניות"
STR_BOOKMARK_ADDED: "הסימנייה התווספה"
STR_BOOKMARK_REMOVED: "הסימנייה הוסרה"
STR_QUICK_RESUME: "חזרה מהירה"
STR_REMOVE_FROM_RECENTS: "להסיר מרשימת הספרים האחרונים?"
STR_CONFIRM_DELETE_BOOKMARK: "למחוק סימנייה זו?"
STR_LONG_PRESS_MENU: "לחיצה ארוכה על אישור"
STR_KOSYNC: "KOSync"
STR_BOOKMARK_OPTION: "סימנייה"
STR_PWR_BTN_FOOTNOTE_BACK: "חזרה מהירה מהערות שוליים"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u דקות"
STR_SLEEP_NEVER: "אף פעם"
STR_SLEEP_TIMER_STEP_HINT: "שמאל/ימין: 1 דק' למעלה/למטה: 5 דק'"
+6 -1
View File
@@ -74,6 +74,7 @@ STR_ORIENTATION: "Olvasási irány"
STR_SIDE_BTN_LAYOUT: "Oldalsó gomb elrendezés (olvasó)"
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Elülső gombok tájolása"
STR_LONG_PRESS_SKIP: "Hosszú nyomás - fejezet ugrás"
STR_FONT_PREVIEW_TEXT: "Egy hűtlen vejét fülöncsípő, dühös mexikói úr Wesselényinél mázol Quitóban"
STR_FONT_FAMILY: "Olvasó betűkészlet"
STR_FONT_SIZE: "Olvasó betűméret"
STR_LINE_SPACING: "Olvasó sorköz"
@@ -131,7 +132,8 @@ STR_SLEEP: "Alvás"
STR_PAGE_TURN: "Lapozás"
STR_PORTRAIT: "Álló"
STR_LANDSCAPE_CW: "Fekvő jobbra"
STR_INVERTED: "Fordított"
STR_INVERTED: "Invertált"
STR_ORIENTATION_INVERTED: "Álló 180°"
STR_LANDSCAPE_CCW: "Fekvő balra"
STR_PREV_NEXT: "Előző/Következő"
STR_NEXT_PREV: "Következő/Előző"
@@ -183,6 +185,9 @@ STR_HOME: "« Főoldal"
STR_SELECT: "Kiválasztás"
STR_SELECTED: "Kiválasztva"
STR_TOGGLE: "Váltás"
STR_TOGGLE_BOOKMARK: "Könyvjelző váltása"
STR_BOOKMARK_REMOVED: "Könyvjelző eltávolítva."
STR_HOLD_OPEN_TO_DELETE: "Tartsa lenyomva a Megnyitás gombot a törléshez"
STR_CONFIRM: "Megerősítés"
STR_CANCEL: "Mégse"
STR_CONNECT: "Csatlakozás"
+11 -4
View File
@@ -77,6 +77,8 @@ STR_LONG_PRESS_BEHAVIOR: "Press. lunga pul. laterali"
STR_LONG_PRESS_BEHAVIOR_OFF: "OFF"
STR_LONG_PRESS_BEHAVIOR_SKIP: "Salta capitolo"
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Orientamento"
STR_LONG_PRESS_MENU: "Menu press. lunga"
STR_FONT_PREVIEW_TEXT: "Pranzo d'acqua fa volti sghembi"
STR_FONT_FAMILY: "Font lettore"
STR_FONT_SIZE: "Dimensione font"
STR_LINE_SPACING: "Interlinea lettore"
@@ -135,7 +137,8 @@ STR_PAGE_TURN: "Cambio pagina"
STR_FORCE_REFRESH: "Refresh"
STR_PORTRAIT: "Verticale"
STR_LANDSCAPE_CW: "Orizzontale Dx"
STR_INVERTED: "Capovolto"
STR_INVERTED: "Invertito"
STR_ORIENTATION_INVERTED: "Verticale 180°"
STR_LANDSCAPE_CCW: "Orizzontale Sx"
STR_PREV_NEXT: "Prec/Succ"
STR_NEXT_PREV: "Succ/Prec"
@@ -190,6 +193,7 @@ STR_HOME: "« Home"
STR_SELECT: "Seleziona"
STR_SELECTED: "Selezionato"
STR_TOGGLE: "Cambia"
STR_TOGGLE_BOOKMARK: "Attiva/disattiva segnalibro"
STR_CONFIRM: "Conferma"
STR_CANCEL: "Annulla"
STR_CONNECT: "Connetti"
@@ -355,9 +359,9 @@ STR_FIRMWARE_WRITE_FAILED: "Aggiornamento firmware non riuscito"
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Non spegnere il dispositivo!"
STR_RECOVERY_MODE: "Modalità ripristino"
STR_RECOVERY_MODE_HINT: "Metti firmware.bin nella scheda SD e selezionalo"
STR_BOOKMARK_INSTRUCTIONS: "Tieni premuto Conferma nel lettore per creare un segnalibro"
STR_BOOKMARKS: "Segnalibri"
STR_BOOKMARK_ADDED: "Segnalibro aggiunto"
STR_BOOKMARK_REMOVED: "Segnalibro rimosso"
STR_CONFIRM_DELETE_BOOKMARK: "Eliminare questo segnalibro?"
STR_CLOCK_SYNC_NO_WIFI_HINT: "Connettiti prima al Wi-Fi e poi riprova"
STR_CLOCK_SYNC_OK: "Orologio sincronizzato"
@@ -372,7 +376,10 @@ STR_CLOCK_SYNCING: "Sincronizzazione con il server NTP..."
STR_CLOCK_SYNC_FAIL: "Sincronizzazione non riuscita"
STR_CLOCK_SYNC_NOW: "Sincronizza l'orologio adesso"
STR_CLOCK_SYNC_NO_WIFI: "Wi-Fi non connesso"
STR_HOLD_CONFIRM_TO_DELETE: "Tieni premuto Conferma per cancellare"
STR_HOLD_OPEN_TO_DELETE: "Tieni premuto Apri per eliminare"
STR_NEXT_FIELD: "Succ."
STR_CURRENT_TIME: "Ora attuale: "
STR_DISABLED: "Disattivato"
STR_DISABLED: "Disattivato"
STR_BOOKMARK_OPTION: "Segnalibro"
STR_KOSYNC: "KOSync"
STR_PWR_BTN_FOOTNOTE_BACK: "Rientro rapido dalle note"
+6 -1
View File
@@ -69,6 +69,7 @@ STR_ORIENTATION: "Оқу бағдары"
STR_SIDE_BTN_LAYOUT: "Бүйірлік түймелер орналасуы (оқырман)"
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Алдыңғы түймелерді бағдарлау"
STR_LONG_PRESS_SKIP: "Ұзақ басу арқылы тарау өткізу"
STR_FONT_PREVIEW_TEXT: "Канагаттандырылмагандыктарыныздан"
STR_FONT_FAMILY: "Оқырман қаріп тобы"
STR_FONT_SIZE: "Интерфейс қаріп өлшемі"
STR_LINE_SPACING: "Оқырман жол аралығы"
@@ -125,7 +126,8 @@ STR_SLEEP: "Ұйқы"
STR_PAGE_TURN: "Бет аудару"
STR_PORTRAIT: "Тік бағдар"
STR_LANDSCAPE_CW: "Көлденең (сағат бағытымен)"
STR_INVERTED: "Төңкерілген"
STR_INVERTED: "Инверсия"
STR_ORIENTATION_INVERTED: "Тік бағдар 180°"
STR_LANDSCAPE_CCW: "Көлденең (сағат тіліне қарсы)"
STR_PREV_NEXT: "Алдыңғы/Келесі"
STR_NEXT_PREV: "Келесі/Алдыңғы"
@@ -176,6 +178,9 @@ STR_EXIT: "« Шығу"
STR_HOME: "« Басты"
STR_SELECT: "Таңдау"
STR_TOGGLE: "Ауыстыру"
STR_TOGGLE_BOOKMARK: "Бетбелгіні ауыстыру"
STR_BOOKMARK_REMOVED: "Бетбелгі жойылды."
STR_HOLD_OPEN_TO_DELETE: "Жою үшін Ашу түймесін ұстап тұрыңыз"
STR_CONFIRM: "Растау"
STR_CANCEL: "Болдырмау"
STR_CONNECT: "Қосылу"
+6 -1
View File
@@ -74,6 +74,7 @@ STR_ORIENTATION: "Orientacija"
STR_SIDE_BTN_LAYOUT: "Šoniniai mygtukai"
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientuoti priekinius mygtukus"
STR_LONG_PRESS_SKIP: "Praleisti skyrių (ilgai)"
STR_FONT_PREVIEW_TEXT: "Įlinkdama fechtuotojo špaga sublykčiojusi pragręžė apvalų arbūzą"
STR_FONT_FAMILY: "Šriftas"
STR_FONT_SIZE: "Šrifto dydis"
STR_LINE_SPACING: "Tarpai tarp eilučių"
@@ -131,7 +132,8 @@ STR_SLEEP: "Miegas"
STR_PAGE_TURN: "Versti psl."
STR_PORTRAIT: "Stačias"
STR_LANDSCAPE_CW: "Gulsčias (P)"
STR_INVERTED: "Apverstas"
STR_INVERTED: "Invertuotas"
STR_ORIENTATION_INVERTED: "Stačias 180°"
STR_LANDSCAPE_CCW: "Gulsčias (A)"
STR_PREV_NEXT: "Atgal/Pirmyn"
STR_NEXT_PREV: "Pirmyn/Atgal"
@@ -183,6 +185,9 @@ STR_HOME: "« Pradžia"
STR_SELECT: "Rinktis"
STR_SELECTED: "Pasirinkta"
STR_TOGGLE: "Keisti"
STR_TOGGLE_BOOKMARK: "Perjungti žymę"
STR_BOOKMARK_REMOVED: "Žymė pašalinta."
STR_HOLD_OPEN_TO_DELETE: "Laikykite Atidaryti, kad ištrintumėte"
STR_CONFIRM: "Gerai"
STR_CANCEL: "Atšaukti"
STR_CONNECT: "Jungtis"
+6 -1
View File
@@ -77,6 +77,7 @@ STR_LONG_PRESS_BEHAVIOR: "Funkcja długiego przyciśnięcia"
STR_LONG_PRESS_BEHAVIOR_OFF: "Wył."
STR_LONG_PRESS_BEHAVIOR_SKIP: "Przeskocz rozdział"
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Orientacja ekranu"
STR_FONT_PREVIEW_TEXT: "Pchnąć w tę łódź jeża lub ośm skrzyń fig"
STR_FONT_FAMILY: "Czcionka"
STR_FONT_SIZE: "Rozmiar czcionki"
STR_LINE_SPACING: "Odstępy między wierszami"
@@ -135,7 +136,8 @@ STR_PAGE_TURN: "Nast. str."
STR_FORCE_REFRESH: "Odśwież ekran"
STR_PORTRAIT: "Pionowo"
STR_LANDSCAPE_CW: "Poziomo P"
STR_INVERTED: "Odwrócony"
STR_INVERTED: "Inwersja"
STR_ORIENTATION_INVERTED: "Pionowo 180°"
STR_LANDSCAPE_CCW: "Poziomo L"
STR_PREV_NEXT: "Poprz./Nast."
STR_NEXT_PREV: "Nast./Poprz."
@@ -190,6 +192,9 @@ STR_HOME: "« Home"
STR_SELECT: "Wybierz"
STR_SELECTED: "Wybrano"
STR_TOGGLE: "Zmień"
STR_TOGGLE_BOOKMARK: "Przełącz zakładkę"
STR_BOOKMARK_REMOVED: "Zakładka usunięta."
STR_HOLD_OPEN_TO_DELETE: "Przytrzymaj Otwórz, aby usunąć"
STR_CONFIRM: "Potwierdź"
STR_CANCEL: "Anuluj"
STR_CONNECT: "Połącz"
+5
View File
@@ -73,6 +73,7 @@ STR_LONG_PRESS_BEHAVIOR: "Comportamento do botão de premir e segurar"
STR_LONG_PRESS_BEHAVIOR_OFF: "Desligado"
STR_LONG_PRESS_BEHAVIOR_SKIP: "Saltar capítulo"
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Alterar orientação"
STR_FONT_PREVIEW_TEXT: "Vejo galã sexy pôr quinze kiwis à força em baú achatado"
STR_FONT_FAMILY: "Fonte do leitor"
STR_FONT_SIZE: "Tam. fonte UI"
STR_LINE_SPACING: "Espaçamento entre linhas"
@@ -130,6 +131,7 @@ STR_PAGE_TURN: "Virar página"
STR_PORTRAIT: "Retrato"
STR_LANDSCAPE_CW: "Paisagem H"
STR_INVERTED: "Invertido"
STR_ORIENTATION_INVERTED: "Retrato 180°"
STR_LANDSCAPE_CCW: "Paisagem AH"
STR_PREV_NEXT: "Ant/Próx"
STR_NEXT_PREV: "Próx/Ant"
@@ -180,6 +182,9 @@ STR_EXIT: "« Sair"
STR_HOME: "« Início"
STR_SELECT: "Escolher"
STR_TOGGLE: "Alternar"
STR_TOGGLE_BOOKMARK: "Alternar marcador"
STR_BOOKMARK_REMOVED: "Marcador removido."
STR_HOLD_OPEN_TO_DELETE: "Mantenha Abrir pressionado para excluir"
STR_CONFIRM: "Confirmar"
STR_CANCEL: "Cancelar"
STR_CONNECT: "Conectar"
+5
View File
@@ -77,6 +77,7 @@ STR_LONG_PRESS_BEHAVIOR: "Comportament buton apăsat lung"
STR_LONG_PRESS_BEHAVIOR_OFF: "Dezactivat"
STR_LONG_PRESS_BEHAVIOR_SKIP: "Sărire capitol"
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Schimbă orientarea"
STR_FONT_PREVIEW_TEXT: "Încă vând gem, whisky bej și tequila roz, preț fix"
STR_FONT_FAMILY: "Familie font lectură"
STR_FONT_SIZE: "Dimensiune font"
STR_LINE_SPACING: "Spaţiere între rânduri"
@@ -135,6 +136,7 @@ STR_PAGE_TURN: "Răsfoire pagină"
STR_PORTRAIT: "Vertical"
STR_LANDSCAPE_CW: "Orizontal dreapta"
STR_INVERTED: "Invers"
STR_ORIENTATION_INVERTED: "Vertical 180°"
STR_LANDSCAPE_CCW: "Orizontal stânga"
STR_PREV_NEXT: "Înainte/Înapoi"
STR_NEXT_PREV: "Înapoi/Înainte"
@@ -186,6 +188,9 @@ STR_HOME: "« Acasă"
STR_SELECT: "Selectează"
STR_SELECTED: "Selectat"
STR_TOGGLE: "Schimbă"
STR_TOGGLE_BOOKMARK: "Comută marcajul"
STR_BOOKMARK_REMOVED: "Marcaj eliminat."
STR_HOLD_OPEN_TO_DELETE: "Țineți apăsat Deschideți pentru a șterge"
STR_CONFIRM: "Confirmă"
STR_CANCEL: "Anulare"
STR_CONNECT: "Conectare"
+9 -3
View File
@@ -78,6 +78,8 @@ STR_LONG_PRESS_BEHAVIOR: "Долгое нажатие"
STR_LONG_PRESS_BEHAVIOR_OFF: "Ничего"
STR_LONG_PRESS_BEHAVIOR_SKIP: "Пропуск главы"
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Изменить ориентацию"
STR_LONG_PRESS_MENU: "Долгое нажатие меню"
STR_FONT_PREVIEW_TEXT: "Съешь ещё этих мягких французских булок, да выпей же чаю"
STR_FONT_FAMILY: "Шрифт чтения"
STR_FONT_SIZE: "Размер шрифта интерфейса"
STR_LINE_SPACING: "Межстрочный интервал"
@@ -138,10 +140,13 @@ STR_FORCE_REFRESH: "Обновление экрана"
STR_PORTRAIT: "Портрет"
STR_LANDSCAPE_CW: "Ландшафт (CW)"
STR_INVERTED: "Инверсия"
STR_ORIENTATION_INVERTED: "Портрет 180°"
STR_LANDSCAPE_CCW: "Ландшафт (CCW)"
STR_PREV_NEXT: "Назад/Вперёд"
STR_NEXT_PREV: "Вперёд/Назад"
STR_DISABLED: "Выключены"
STR_KOSYNC: "KOSync"
STR_BOOKMARK_OPTION: "Закладка"
STR_DISABLED: "Выключено"
STR_NOTO_SERIF: "Noto Serif"
STR_NOTO_SANS: "Noto Sans"
STR_SMALL: "Маленький"
@@ -176,8 +181,7 @@ STR_DOWNLOADING: "Загрузка..."
STR_DOWNLOAD_FAILED: "Ошибка загрузки"
STR_ERROR_MSG: "Ошибка:"
STR_UNNAMED: "Без имени"
STR_HOLD_CONFIRM_TO_DELETE: "Удерживайте ОТКРЫТЬ для удаления закладки"
STR_BOOKMARK_INSTRUCTIONS: "Удерживайте ВЫБРАТЬ для добавления закладки"
STR_HOLD_OPEN_TO_DELETE: "Удерживайте Открыть для удаления"
STR_NO_SERVER_URL: "URL сервера не настроен"
STR_FETCH_FEED_FAILED: "Не удалось получить ленту"
STR_PARSE_FEED_FAILED: "Не удалось обработать ленту"
@@ -195,6 +199,7 @@ STR_HOME: "« Главная"
STR_SELECT: "Выбрать"
STR_SELECTED: "Выбран"
STR_TOGGLE: "Выбор"
STR_TOGGLE_BOOKMARK: "Переключить закладку"
STR_CONFIRM: "Подтв."
STR_CANCEL: "Отмена"
STR_CONNECT: "Подкл."
@@ -257,6 +262,7 @@ STR_SUNLIGHT_FADING_FIX: "Компенсация выцветания"
STR_REMAP_FRONT_BUTTONS: "Переназначить передние кнопки"
STR_BOOKMARKS: "Закладки"
STR_BOOKMARK_ADDED: "Закладка добавлена"
STR_BOOKMARK_REMOVED: "Закладка удалена"
STR_OPDS_BROWSER: "OPDS браузер"
STR_SEARCH: "Поиск"
STR_COVER_CUSTOM: "Обложка + Свой"
+9 -6
View File
@@ -78,6 +78,7 @@ STR_LONG_PRESS_BEHAVIOR: "Správanie pri dlhom stlačení tlačidla"
STR_LONG_PRESS_BEHAVIOR_OFF: "VYP"
STR_LONG_PRESS_BEHAVIOR_SKIP: "Preskočiť kapitolu"
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Zmeniť orientáciu"
STR_FONT_PREVIEW_TEXT: "Vypätá dcéra grófa Maxwella s IQ nižším ako kôň núti čeľaď hrýzť hŕbu jabĺk"
STR_FONT_FAMILY: "Rodina písiem čítačky"
STR_FONT_SIZE: "Veľkosť písma rozhrania"
STR_LINE_SPACING: "Riadkovanie čítačky"
@@ -136,7 +137,8 @@ STR_PAGE_TURN: "Otáčanie stránok"
STR_FORCE_REFRESH: "Obnoviť obrazovku"
STR_PORTRAIT: "Na výšku"
STR_LANDSCAPE_CW: "Na šírku v smere hodinových ručičiek"
STR_INVERTED: "Obrátený"
STR_INVERTED: "Invertovaný"
STR_ORIENTATION_INVERTED: "Na výšku 180°"
STR_LANDSCAPE_CCW: "Na šírku proti smeru hodinových ručičiek"
STR_PREV_NEXT: "Predchádzajúca/Nasledujúca"
STR_NEXT_PREV: "Nasledujúca/Predchádzajúca"
@@ -175,8 +177,7 @@ STR_DOWNLOADING: "Sťahovanie..."
STR_DOWNLOAD_FAILED: "Sťahovanie zlyhalo"
STR_ERROR_MSG: "Chyba:"
STR_UNNAMED: "Nepomenované"
STR_HOLD_CONFIRM_TO_DELETE: "Podrž potvrdiť pre vymazanie"
STR_BOOKMARK_INSTRUCTIONS: "Podrž tlačidlo Potvrdiť pre vytvorenie záložky."
STR_HOLD_OPEN_TO_DELETE: "Podržte Otvoriť pre vymazanie"
STR_NO_SERVER_URL: "Nie je nakonfigurovaná URL adresa servera"
STR_FETCH_FEED_FAILED: "Načítanie kanála zlyhalo"
STR_PARSE_FEED_FAILED: "Spracovanie kanála zlyhalo"
@@ -193,7 +194,8 @@ STR_EXIT: "« Koniec"
STR_HOME: "« Domov"
STR_SELECT: "Vybrať"
STR_SELECTED: "Vybrané"
STR_TOGGLE: "Prepnúť"
STR_TOGGLE: "Prepnúť"
STR_TOGGLE_BOOKMARK: "Prepnúť záložku"
STR_CONFIRM: "Potvrdiť"
STR_CANCEL: "Zrušiť"
STR_CONNECT: "Pripojiť"
@@ -254,8 +256,9 @@ STR_THEME_ROUNDEDRAFF: "RoundedRaff"
STR_THEME_LYRA_EXTENDED: "Lyra Extended"
STR_SUNLIGHT_FADING_FIX: "Oprava blednutia na slnku"
STR_REMAP_FRONT_BUTTONS: "Premapovať predné tlačidlá"
STR_BOOKMARKS: "Záložky"
STR_BOOKMARK_ADDED: "Záložka pridaná."
STR_BOOKMARKS: "Záložky"
STR_BOOKMARK_ADDED: "Záložka pridaná."
STR_BOOKMARK_REMOVED: "Záložka odstránená."
STR_OPDS_BROWSER: "Prehliadač OPDS"
STR_SEARCH: "Hľadať"
STR_COVER_CUSTOM: "Obálka + Vlastné"
+6 -1
View File
@@ -74,6 +74,7 @@ STR_ORIENTATION: "Orientacija branja"
STR_SIDE_BTN_LAYOUT: "Razpored stranskih gumbov"
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Usmeri sprednje gumbe"
STR_LONG_PRESS_SKIP: "Dolgi pritisk za preskok poglavja"
STR_FONT_PREVIEW_TEXT: "V kožuščku hudobnega fanta stopiclja mizar"
STR_FONT_FAMILY: "Pisava bralnika"
STR_FONT_SIZE: "Velikost pisave"
STR_LINE_SPACING: "Razmik med vrsticami"
@@ -131,7 +132,8 @@ STR_SLEEP: "Spanje"
STR_PAGE_TURN: "Obračanje strani"
STR_PORTRAIT: "Pokončno"
STR_LANDSCAPE_CW: "Ležeče (v smeri urinega kazalca)"
STR_INVERTED: "Obrnjeno"
STR_INVERTED: "Invertirano"
STR_ORIENTATION_INVERTED: "Pokončno 180°"
STR_LANDSCAPE_CCW: "Ležeče (proti smeri urinega kazalca)"
STR_PREV_NEXT: "Nazaj/Naprej"
STR_NEXT_PREV: "Naprej/Nazaj"
@@ -183,6 +185,9 @@ STR_HOME: "« Domov"
STR_SELECT: "Izberi"
STR_SELECTED: "Izbrano"
STR_TOGGLE: "Preklopi"
STR_TOGGLE_BOOKMARK: "Preklopi zaznamek"
STR_BOOKMARK_REMOVED: "Zaznamek odstranjen."
STR_HOLD_OPEN_TO_DELETE: "Držite Odpri za brisanje"
STR_CONFIRM: "Potrdi"
STR_CANCEL: "Prekliči"
STR_CONNECT: "Poveži"
+10 -3
View File
@@ -77,6 +77,8 @@ STR_LONG_PRESS_BEHAVIOR: "Al mantener pulsado un botón"
STR_LONG_PRESS_BEHAVIOR_OFF: "No hacer nada"
STR_LONG_PRESS_BEHAVIOR_SKIP: "Saltar capítulo"
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Cambiar orient."
STR_LONG_PRESS_MENU: "Función de pulsación larga"
STR_FONT_PREVIEW_TEXT: "Benjamín pidió una bebida de kiwi y fresa. Noé, sin vergüenza, la más exquisita champaña del menú"
STR_FONT_FAMILY: "Tipografía"
STR_FONT_SIZE: "Tamaño"
STR_LINE_SPACING: "Interlineado"
@@ -136,9 +138,12 @@ STR_FORCE_REFRESH: "Refrescar pant."
STR_PORTRAIT: "Vertical"
STR_LANDSCAPE_CW: "Horizontal (horario)"
STR_INVERTED: "Invertido"
STR_ORIENTATION_INVERTED: "Al revés"
STR_LANDSCAPE_CCW: "Horizontal (antihorario)"
STR_PREV_NEXT: "Ant./Sig."
STR_NEXT_PREV: "Sig./Ant."
STR_KOSYNC: "KOSync"
STR_BOOKMARK_OPTION: "Marcador"
STR_DISABLED: "Desactivados"
STR_NOTO_SERIF: "Noto Serif"
STR_NOTO_SANS: "Noto Sans"
@@ -174,8 +179,7 @@ STR_DOWNLOADING: "Descargando..."
STR_DOWNLOAD_FAILED: "Fallo de descarga"
STR_ERROR_MSG: "Error:"
STR_UNNAMED: "Sin nombre"
STR_HOLD_CONFIRM_TO_DELETE: "Mantenga pulsado Confirmar para borrar"
STR_BOOKMARK_INSTRUCTIONS: "Mantenga pulsado Confirmar en el lector para crear un marcador."
STR_HOLD_OPEN_TO_DELETE: "Mantenga pulsado Abrir para borrar"
STR_NO_SERVER_URL: "No se configuró URL de servidor"
STR_FETCH_FEED_FAILED: "Fallo al obtener el feed"
STR_PARSE_FEED_FAILED: "Fallo al procesar el feed"
@@ -193,6 +197,7 @@ STR_HOME: "« Inicio"
STR_SELECT: "Selecc."
STR_SELECTED: "Seleccionado"
STR_TOGGLE: "Cambiar"
STR_TOGGLE_BOOKMARK: "Alternar marcador"
STR_CONFIRM: "Confirmar"
STR_CANCEL: "Cancelar"
STR_CONNECT: "Conectar"
@@ -256,6 +261,7 @@ STR_QUICK_RESUME_TIMEOUT: "Reanudación rápida tras tiempo"
STR_REMAP_FRONT_BUTTONS: "Reconfigurar botones frontales"
STR_BOOKMARKS: "Marcadores"
STR_BOOKMARK_ADDED: "Marcador añadido."
STR_BOOKMARK_REMOVED: "Marcador eliminado."
STR_OPDS_BROWSER: "Navegador OPDS"
STR_SEARCH: "Buscar"
STR_COVER_CUSTOM: "Portada + Pers."
@@ -318,8 +324,9 @@ STR_BOOK_S_STYLE: "Estilo del libro"
STR_EMBEDDED_STYLE: "Estilo integrado"
STR_FOCUS_READING: "Lectura enfocada"
STR_OPDS_SERVER_URL: "URL del servidor OPDS"
STR_PWR_BTN_FOOTNOTE_BACK: "Retorno rápido desde las notas al pie"
STR_SET_SLEEP_COVER: "Pant. sus."
STR_FOOTNOTES: "Pie de página"
STR_FOOTNOTES: "Notas al pie"
STR_NO_FOOTNOTES: "No hay notas al pie de esta página"
STR_LINK: "[enlace]"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min."
+5 -2
View File
@@ -78,6 +78,7 @@ STR_LONG_PRESS_BEHAVIOR: "Beteende vid lång knapptryckning"
STR_LONG_PRESS_BEHAVIOR_OFF: "AV"
STR_LONG_PRESS_BEHAVIOR_SKIP: "Hoppa över kapitel"
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Ändra orientering"
STR_FONT_PREVIEW_TEXT: "Flygande bäckasiner söka hwila på mjuka tuvor"
STR_FONT_FAMILY: "Eboksläsarens typsnittsfamilj"
STR_FONT_SIZE: "Eboksläsarens typsnittsstorlek"
STR_LINE_SPACING: "Eboksläsarens linjemellanrum"
@@ -137,6 +138,7 @@ STR_FORCE_REFRESH: "Uppdatera skärmen"
STR_PORTRAIT: "Porträtt"
STR_LANDSCAPE_CW: "Landskap medurs"
STR_INVERTED: "Inverterad"
STR_ORIENTATION_INVERTED: "Porträtt 180°"
STR_LANDSCAPE_CCW: "Landskap moturs"
STR_PREV_NEXT: "Förra/Nästa"
STR_NEXT_PREV: "Nästa/Förra"
@@ -175,8 +177,7 @@ STR_DOWNLOADING: "Laddar ner…"
STR_DOWNLOAD_FAILED: "Nedladdning misslyckades"
STR_ERROR_MSG: "Fel:"
STR_UNNAMED: "Ej namngiven"
STR_HOLD_CONFIRM_TO_DELETE: "Håll ned Bekräfta för att radera"
STR_BOOKMARK_INSTRUCTIONS: "Håll Bekräfta i läsaren för att skapa ett bokmärke."
STR_HOLD_OPEN_TO_DELETE: "Håll ned Öppna för att radera"
STR_NO_SERVER_URL: "Ingen serveradress konfigurerad"
STR_FETCH_FEED_FAILED: "Misslyckades att hämta flöde"
STR_PARSE_FEED_FAILED: "Misslyckades att analysera flöde"
@@ -194,6 +195,7 @@ STR_HOME: "« Hem"
STR_SELECT: "Välj "
STR_SELECTED: "Vald"
STR_TOGGLE: "Växla"
STR_TOGGLE_BOOKMARK: "Växla bokmärke"
STR_CONFIRM: "Bekräfta"
STR_CANCEL: "Avbryt"
STR_CONNECT: "Anslut"
@@ -257,6 +259,7 @@ STR_QUICK_RESUME_TIMEOUT: "Snabb återupptagning efter timeout"
STR_REMAP_FRONT_BUTTONS: "Ändra frontknappar"
STR_BOOKMARKS: "Bokmärken"
STR_BOOKMARK_ADDED: "Bokmärke tillagt."
STR_BOOKMARK_REMOVED: "Bokmärke borttaget."
STR_OPDS_BROWSER: "OPDS-webbläsare"
STR_SEARCH: "Sök"
STR_COVER_CUSTOM: "Omslag + Valfri"
+6 -1
View File
@@ -72,6 +72,7 @@ STR_LONG_PRESS_BEHAVIOR: "Long-press button behavior"
STR_LONG_PRESS_BEHAVIOR_OFF: "OFF"
STR_LONG_PRESS_BEHAVIOR_SKIP: "Chapter skip"
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Orientation change"
STR_FONT_PREVIEW_TEXT: "Pijamalı hasta yağız şoföre çabucak güvendi"
STR_FONT_FAMILY: "Okuyucu Yazı Tipi Ailesi"
STR_FONT_SIZE: "Arayüz Yazı Boyutu"
STR_LINE_SPACING: "Okuyucu Satır Aralığı"
@@ -129,7 +130,8 @@ STR_SLEEP: "Uyku"
STR_PAGE_TURN: "Sayfa Çevirme"
STR_PORTRAIT: "Dikey"
STR_LANDSCAPE_CW: "Yatay (Saat Yönü)"
STR_INVERTED: "Ters"
STR_INVERTED: "Negatif"
STR_ORIENTATION_INVERTED: "Dikey 180°"
STR_LANDSCAPE_CCW: "Yatay (Saat Yönü Tersi)"
STR_PREV_NEXT: "Önceki/Sonraki"
STR_NEXT_PREV: "Sonraki/Önceki"
@@ -180,6 +182,9 @@ STR_EXIT: "« Çıkış"
STR_HOME: "« Ana Sayfa"
STR_SELECT: "Seç"
STR_TOGGLE: "Değiştir"
STR_TOGGLE_BOOKMARK: "Yer imini değiştir"
STR_BOOKMARK_REMOVED: "Yer imi kaldırıldı."
STR_HOLD_OPEN_TO_DELETE: "Silmek için Aç düğmesini basılı tutun"
STR_CONFIRM: "Onayla"
STR_CANCEL: "İptal"
STR_CONNECT: "Bağlan"
+31 -6
View File
@@ -61,6 +61,7 @@ STR_CAT_READER: "Читач"
STR_CAT_CONTROLS: "Кнопки"
STR_CAT_SYSTEM: "Система"
STR_SLEEP_SCREEN: "Екран у режимі сну"
STR_QUICK_RESUME_TIMEOUT: "Швидке поверн. після таймауту"
STR_SLEEP_COVER_MODE: "Режим заповнення"
STR_HIDE_BATTERY: "Приховати % батареї"
STR_EXTRA_SPACING: "Додатковий інтервал між абзацами"
@@ -77,6 +78,7 @@ STR_LONG_PRESS_BEHAVIOR: "Поведінка при довгому настик
STR_LONG_PRESS_BEHAVIOR_OFF: "Немає"
STR_LONG_PRESS_BEHAVIOR_SKIP: "Наступ. розділ (утримув.)"
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Зміна орієнтації екрану"
STR_FONT_PREVIEW_TEXT: "Єхидна, ґава, їжак ще й шиплячі плазуни бігцем форсують Янцзи"
STR_FONT_FAMILY: "Шрифт"
STR_FONT_SIZE: "Розмір шрифту"
STR_LINE_SPACING: "Міжрядковий інтервал"
@@ -85,8 +87,8 @@ STR_PARA_ALIGNMENT: "Вирівнювання тексту"
STR_HYPHENATION: "Перенесення слів"
STR_TIME_TO_SLEEP: "Перехід в режим сну"
STR_SHOW_HIDDEN_FILES: "Показати приховані файли"
STR_REMOVE_READ_FROM_RECENTS: "Очищати прочитані книги зі списку останніх"
STR_MOVE_FINISHED_TO_READ: "Переміщати прочитані книги до теки Read"
STR_REMOVE_READ_FROM_RECENTS: "Приховувати прочитані книги"
STR_MOVE_FINISHED_TO_READ: "Переміщати прочит. в теку Read"
STR_REFRESH_FREQ: "Частота оновлення екрану"
STR_KOREADER_SYNC: "Синхронізація KOReader"
STR_CHECK_UPDATES: "Перевірити оновлення системи"
@@ -135,10 +137,12 @@ STR_PAGE_TURN: "Наст. сторінка"
STR_FORCE_REFRESH: "Оновити екран"
STR_PORTRAIT: "Книжкова"
STR_LANDSCAPE_CW: "Альбом. за год."
STR_INVERTED: "Перевернутий"
STR_INVERTED: "Інверсія"
STR_ORIENTATION_INVERTED: "Книжкова 180°"
STR_LANDSCAPE_CCW: "Альбом. проти год."
STR_PREV_NEXT: "Попер/Наст"
STR_NEXT_PREV: "Наст/Попер"
STR_DISABLED: "Вимкнуто"
STR_NOTO_SERIF: "Noto Serif"
STR_NOTO_SANS: "Noto Sans"
STR_SMALL: "Малий"
@@ -173,6 +177,7 @@ STR_DOWNLOADING: "Завантаження..."
STR_DOWNLOAD_FAILED: "Завантаження не вдалося"
STR_ERROR_MSG: "Помилка:"
STR_UNNAMED: "Без назви"
STR_HOLD_OPEN_TO_DELETE: "Утримуйте Відкрити, щоб видалити"
STR_NO_SERVER_URL: "URL сервера не налаштовано"
STR_FETCH_FEED_FAILED: "Не вдалося отримати стрічку"
STR_PARSE_FEED_FAILED: "Не вдалося розпарсити стрічку"
@@ -190,6 +195,7 @@ STR_HOME: "« Додому"
STR_SELECT: "Вибрати"
STR_SELECTED: "Вибрано"
STR_TOGGLE: "Обрати"
STR_TOGGLE_BOOKMARK: "Перемкнути закладку"
STR_CONFIRM: "Підтвердити"
STR_CANCEL: "Скасувати"
STR_CONNECT: "Приєдн."
@@ -228,18 +234,35 @@ STR_BATTERY: "Акумулятор"
STR_XTC_STATUS_BAR: "XTC Рядок прогресу"
STR_BOTTOM: "Низ"
STR_TOP: "Верх"
STR_CLOCK: "Годинник"
STR_CLOCK_UTC_OFFSET: "Часовий пояс"
STR_CLOCK_FORMAT: "Формат Годинника"
STR_CLOCK_FORMAT_24H: "24-години"
STR_CLOCK_FORMAT_12H: "12-годин"
STR_CURRENT_TIME: "Поточний час:"
STR_NEXT_FIELD: "наступний"
STR_CLOCK_SYNC: "Синхр. Годинник"
STR_CLOCK_SYNC_NOW: "Синхр. годин. зараз"
STR_CLOCK_SYNCING: "Синхр. через NTP..."
STR_CLOCK_SYNC_OK: "Успішна синхр."
STR_CLOCK_SYNC_FAIL: "Невдала синхр. "
STR_CLOCK_SYNC_NO_WIFI: "Wi-Fi не під'єднано"
STR_CLOCK_SYNC_NO_WIFI_HINT: "Під'єднайтесь спершу до Wi-Fi, тоді спробуйте знову."
STR_CLOCK_SYNCED: "Годинник синхр."
STR_UI_THEME: "Тема інтерфейсу"
STR_THEME_CLASSIC: "Класична"
STR_THEME_LYRA: "Lyra"
STR_THEME_ROUNDEDRAFF: "RoundedRaff"
STR_THEME_LYRA_EXTENDED: "Lyra Extended"
STR_SUNLIGHT_FADING_FIX: "Виправлення вицвітання на сонці"
STR_QUICK_RESUME_TIMEOUT: "Швидке продовження після таймауту"
STR_REMAP_FRONT_BUTTONS: "Налаштувати передні кнопки"
STR_BOOKMARKS: "Закладки"
STR_BOOKMARK_ADDED: "Закладку додано"
STR_BOOKMARK_REMOVED: "Закладку видалено"
STR_OPDS_BROWSER: "Браузер OPDS"
STR_SEARCH: "Пошук"
STR_COVER_CUSTOM: "Обкл. + власне"
STR_QUICK_RESUME: "Швидке продовження"
STR_QUICK_RESUME: "Швидке поверн."
STR_MENU_RECENT_BOOKS: "Останні книги"
STR_REMOVE_FROM_RECENTS: "Видалити з останніх книг?"
STR_NO_RECENT_BOOKS: "Немає останніх книг"
@@ -265,6 +288,7 @@ STR_GO_HOME_BUTTON: "На головну"
STR_SYNC_PROGRESS: "Прогрес синхронізації"
STR_DELETE_CACHE: "Видалити кеш книги"
STR_DELETE: "Видалити"
STR_CONFIRM_DELETE_BOOKMARK: "Видалити цю закладку?"
STR_DISPLAY_QR: "Показати сторінку як QR-код"
STR_CHAPTER_PREFIX: "Розділ: "
STR_PAGES_SEPARATOR: " сторінок | "
@@ -297,14 +321,15 @@ STR_BOOK_S_STYLE: "Стиль книги"
STR_EMBEDDED_STYLE: "Вбудований стиль"
STR_FOCUS_READING: "Фокусне читання"
STR_OPDS_SERVER_URL: "URL сервера OPDS"
STR_PWR_BTN_FOOTNOTE_BACK: "Швидке повернення з приміток"
STR_SET_SLEEP_COVER: "Як обкл."
STR_FOOTNOTES: "Примітки"
STR_NO_FOOTNOTES: "На цій сторінці немає приміток"
STR_LINK: "[посилання]"
STR_SCREENSHOT_BUTTON: "Знімок екрана"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u хв"
STR_SLEEP_NEVER: "Ніколи"
STR_SLEEP_TIMER_STEP_HINT: "Вліво/Вправо: 1 хв Вгору/Вниз: 5 хв"
STR_SCREENSHOT_BUTTON: "Знімок екрана"
STR_ADD_SERVER: "Додати сервер"
STR_SERVER_NAME: "Назва сервера"
STR_NO_SERVERS: "Не налаштовано жодного сервера OPDS"
+9 -2
View File
@@ -78,6 +78,8 @@ STR_LONG_PRESS_BEHAVIOR: "Comportament de prémer llargament el botó"
STR_LONG_PRESS_BEHAVIOR_OFF: "Desactivat"
STR_LONG_PRESS_BEHAVIOR_SKIP: "Saltar capítols"
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Canvi d'orientació"
STR_LONG_PRESS_MENU: "Funció de pulsació llarga"
STR_FONT_PREVIEW_TEXT: "Jove xef, porti whisky amb quinze glaçons d'hidrogen, coi!"
STR_FONT_FAMILY: "Família de fonts"
STR_FONT_SIZE: "Grandària de la lletra (UI)"
STR_LINE_SPACING: "Interlineat del lector"
@@ -139,9 +141,12 @@ STR_PAGE_TURN: "Canvi de pàgina"
STR_PORTRAIT: "Vertical"
STR_LANDSCAPE_CW: "Horitzontal horari"
STR_INVERTED: "Invertit"
STR_ORIENTATION_INVERTED: "Vertical 180°"
STR_LANDSCAPE_CCW: "Horitzontal antihorari"
STR_PREV_NEXT: "Anterior/Següent"
STR_NEXT_PREV: "Següent/Anterior"
STR_KOSYNC: "KOSync"
STR_BOOKMARK_OPTION: "Punt de llibre"
STR_DISABLED: "Desactivats"
STR_NOTO_SERIF: "Noto Serif"
STR_NOTO_SANS: "Noto Sans"
@@ -176,8 +181,7 @@ STR_DOWNLOADING: "S'està baixant..."
STR_DOWNLOAD_FAILED: "Ha fallat la baixada"
STR_ERROR_MSG: "Error:"
STR_UNNAMED: "Sense nom"
STR_HOLD_CONFIRM_TO_DELETE: "Manteniu premut Confirma per esborrar"
STR_BOOKMARK_INSTRUCTIONS: "Manteniu premut Confirma al lector per crear un punt de llibre."
STR_HOLD_OPEN_TO_DELETE: "Manteniu premut Obre per esborrar"
STR_NO_SERVER_URL: "No s'ha configurat cap URL de servidor"
STR_FETCH_FEED_FAILED: "Ha fallat l'obtenció del feed"
STR_PARSE_FEED_FAILED: "Ha fallat l'anàlisi del feed"
@@ -193,6 +197,7 @@ STR_HOME: "« Inici"
STR_SELECT: "Selecciona"
STR_SELECTED: "Seleccionat"
STR_TOGGLE: "Canvia"
STR_TOGGLE_BOOKMARK: "Canvia punt de llibre"
STR_CONFIRM: "Confirma"
STR_CANCEL: "Cancel·la"
STR_CONNECT: "Connecta"
@@ -236,6 +241,7 @@ STR_SUNLIGHT_FADING_FIX: "Correcció de l'esvaïment pel sol"
STR_REMAP_FRONT_BUTTONS: "Reassigna els botons frontals"
STR_BOOKMARKS: "Punts de llibre"
STR_BOOKMARK_ADDED: "S'ha afegit el punt de llibre."
STR_BOOKMARK_REMOVED: "S'ha eliminat el punt de llibre."
STR_OPDS_BROWSER: "Navegador OPDS"
STR_COVER_CUSTOM: "Portada + Personalitzat"
STR_QUICK_RESUME: "Represa ràpida"
@@ -297,6 +303,7 @@ STR_BOOK_S_STYLE: "Estil del llibre"
STR_EMBEDDED_STYLE: "Estil incrustat"
STR_FOCUS_READING: "Lectura enfocada"
STR_OPDS_SERVER_URL: "URL del servidor OPDS"
STR_PWR_BTN_FOOTNOTE_BACK: "Retorn ràpid des de les notes al peu"
STR_FOOTNOTES: "Notes al peu"
STR_NO_FOOTNOTES: "No hi ha notes al peu en esta pàgina"
STR_LINK: "[enllaç]"
+6 -3
View File
@@ -78,6 +78,7 @@ STR_LONG_PRESS_BEHAVIOR: "Hành vi nhấn giữ nút"
STR_LONG_PRESS_BEHAVIOR_OFF: "TẮT"
STR_LONG_PRESS_BEHAVIOR_SKIP: "Nhảy chương"
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Đổi hướng"
STR_FONT_PREVIEW_TEXT: "Trường quê em do bố của em xây kĩ nên sạch và đẹp lắm"
STR_FONT_FAMILY: "Phông chữ trình đọc"
STR_FONT_SIZE: "Cỡ chữ trình đọc"
STR_LINE_SPACING: "Giãn dòng trình đọc"
@@ -136,7 +137,8 @@ STR_PAGE_TURN: "Lật trang"
STR_FORCE_REFRESH: "Làm tươi màn hình"
STR_PORTRAIT: "Dọc"
STR_LANDSCAPE_CW: "Ngang (thuận)"
STR_INVERTED: "Lật ngược"
STR_INVERTED: "Đảo màu"
STR_ORIENTATION_INVERTED: "Dọc 180°"
STR_LANDSCAPE_CCW: "Ngang (ngược)"
STR_PREV_NEXT: "Trước/Sau"
STR_NEXT_PREV: "Sau/Trước"
@@ -175,8 +177,7 @@ STR_DOWNLOADING: "Đang tải về..."
STR_DOWNLOAD_FAILED: "Tải về thất bại"
STR_ERROR_MSG: "Lỗi:"
STR_UNNAMED: "Không tên"
STR_HOLD_CONFIRM_TO_DELETE: "Giữ Xác nhận để xóa"
STR_BOOKMARK_INSTRUCTIONS: "Giữ Xác nhận trong trình đọc để tạo dấu trang."
STR_HOLD_OPEN_TO_DELETE: "Giữ Mở để xóa"
STR_NO_SERVER_URL: "Chưa cấu hình URL máy chủ"
STR_FETCH_FEED_FAILED: "Không tải được nguồn cấp"
STR_PARSE_FEED_FAILED: "Không phân tích được nguồn cấp"
@@ -194,6 +195,7 @@ STR_HOME: "« Thư viện"
STR_SELECT: "Chọn"
STR_SELECTED: "Đã chọn"
STR_TOGGLE: "Bật/Tắt"
STR_TOGGLE_BOOKMARK: "Bật/tắt dấu trang"
STR_CONFIRM: "Xác nhận"
STR_CANCEL: "Hủy"
STR_CONNECT: "Kết nối"
@@ -256,6 +258,7 @@ STR_SUNLIGHT_FADING_FIX: "Khắc phục mờ dưới nắng"
STR_REMAP_FRONT_BUTTONS: "Gán lại nút mặt trước"
STR_BOOKMARKS: "Dấu trang"
STR_BOOKMARK_ADDED: "Đã thêm dấu trang."
STR_BOOKMARK_REMOVED: "Đã xóa dấu trang."
STR_OPDS_BROWSER: "Trình duyệt OPDS"
STR_SEARCH: "Tìm kiếm"
STR_COVER_CUSTOM: "Ảnh bìa + Tùy chỉnh"
+122 -2
View File
@@ -164,6 +164,7 @@ namespace {
constexpr int MAX_MCU_HEIGHT = 16;
constexpr size_t JPEG_DECODER_SIZE = 20 * 1024;
constexpr size_t MIN_FREE_HEAP = JPEG_DECODER_SIZE + 32 * 1024;
constexpr uint32_t FP_ONE = 1UL << 16;
// Static file pointer for JPEGDEC open callback.
// Safe in single-threaded embedded context; never accessed concurrently.
@@ -208,6 +209,9 @@ struct BmpConvertCtx {
bool needsScaling;
uint32_t scaleX_fp; // source pixels per output pixel, 16.16 fixed-point
uint32_t scaleY_fp;
bool smoothUpscale;
uint32_t smoothScaleX_fp;
uint32_t smoothScaleY_fp;
// Accumulates one MCU row (up to MAX_MCU_HEIGHT source rows × srcWidth pixels)
// Filled column-by-column as JPEGDEC callbacks arrive for the same MCU row
@@ -219,6 +223,13 @@ struct BmpConvertCtx {
std::unique_ptr<uint32_t[]> rowAccum;
std::unique_ptr<uint32_t[]> rowCount;
int smoothNextOutY;
int smoothPrevY;
std::unique_ptr<uint8_t[]> smoothRows;
uint8_t* smoothPrevRow;
uint8_t* smoothCurrRow;
uint8_t* smoothOutRow;
std::unique_ptr<uint8_t[]> bmpRow;
std::unique_ptr<AtkinsonDitherer> atkinsonDitherer;
@@ -265,6 +276,88 @@ static void writeOutputRow(BmpConvertCtx* ctx, const uint8_t* srcRow, int outY)
ctx->bmpOut->write(ctx->bmpRow.get(), ctx->bytesPerRow);
}
// Matches the progressive-JPEG smoothing used by JpegToFramebufferConverter, but stays
// local because cover generation streams dithered BMP rows instead of framebuffer pixels.
static uint32_t interpolationStep(const int srcSize, const int outSize) {
if (srcSize <= 1 || outSize <= 1) return 0;
return (static_cast<uint32_t>(srcSize - 1) << 16) / static_cast<uint32_t>(outSize - 1);
}
static uint32_t interpolatedSourceFp(const int outIndex, const int outSize, const int srcSize, const uint32_t step) {
if (srcSize <= 1 || outSize <= 1) return 0;
if (outIndex >= outSize - 1) return static_cast<uint32_t>(srcSize - 1) << 16;
return static_cast<uint32_t>(outIndex) * step;
}
static void scaleRowLinear(BmpConvertCtx* ctx, const uint8_t* srcRow, uint8_t* dstRow) {
for (int outX = 0; outX < ctx->outWidth; outX++) {
const uint32_t srcX_fp = interpolatedSourceFp(outX, ctx->outWidth, ctx->srcWidth, ctx->smoothScaleX_fp);
const int x0 = srcX_fp >> 16;
const int x1 = (x0 + 1 < ctx->srcWidth) ? (x0 + 1) : x0;
const uint32_t fx = srcX_fp & (FP_ONE - 1);
dstRow[outX] = static_cast<uint8_t>((srcRow[x0] * (FP_ONE - fx) + srcRow[x1] * fx) >> 16);
}
}
static void writeBlendedRow(BmpConvertCtx* ctx, const uint8_t* row0, const uint8_t* row1, const uint32_t fy,
const int outY) {
const uint32_t invFy = FP_ONE - fy;
for (int outX = 0; outX < ctx->outWidth; outX++) {
ctx->smoothOutRow[outX] = static_cast<uint8_t>((row0[outX] * invFy + row1[outX] * fy) >> 16);
}
writeOutputRow(ctx, ctx->smoothOutRow, outY);
}
static void processSmoothSourceRow(BmpConvertCtx* ctx, const uint8_t* srcRow, const int srcY) {
scaleRowLinear(ctx, srcRow, ctx->smoothCurrRow);
if (ctx->smoothPrevY < 0) {
uint8_t* tmp = ctx->smoothPrevRow;
ctx->smoothPrevRow = ctx->smoothCurrRow;
ctx->smoothCurrRow = tmp;
ctx->smoothPrevY = srcY;
if (ctx->srcHeight <= 1) {
while (ctx->smoothNextOutY < ctx->outHeight) {
writeOutputRow(ctx, ctx->smoothPrevRow, ctx->smoothNextOutY);
ctx->smoothNextOutY++;
}
return;
}
return;
}
while (ctx->smoothNextOutY < ctx->outHeight) {
const uint32_t srcY_fp =
interpolatedSourceFp(ctx->smoothNextOutY, ctx->outHeight, ctx->srcHeight, ctx->smoothScaleY_fp);
const int y0 = srcY_fp >> 16;
const int y1 = (y0 + 1 < ctx->srcHeight) ? (y0 + 1) : y0;
if (y1 > srcY) break;
const uint8_t* row0 = (y0 == srcY) ? ctx->smoothCurrRow : ctx->smoothPrevRow;
const uint8_t* row1 = (y1 == srcY) ? ctx->smoothCurrRow : ctx->smoothPrevRow;
writeBlendedRow(ctx, row0, row1, srcY_fp & (FP_ONE - 1), ctx->smoothNextOutY);
ctx->smoothNextOutY++;
}
uint8_t* tmp = ctx->smoothPrevRow;
ctx->smoothPrevRow = ctx->smoothCurrRow;
ctx->smoothCurrRow = tmp;
ctx->smoothPrevY = srcY;
}
static void finishSmoothUpscale(BmpConvertCtx* ctx) {
if (ctx->smoothPrevY < 0) {
LOG_ERR("JPG", "No progressive rows decoded for smoothing");
ctx->error = true;
return;
}
while (ctx->smoothNextOutY < ctx->outHeight) {
writeOutputRow(ctx, ctx->smoothPrevRow, ctx->smoothNextOutY);
ctx->smoothNextOutY++;
}
}
// Flush one scaled output row from Y-axis accumulators and advance currentOutY
static void flushScaledRow(BmpConvertCtx* ctx) {
memset(ctx->bmpRow.get(), 0, ctx->bytesPerRow);
@@ -344,7 +437,9 @@ int bmpDrawCallback(JPEGDRAW* pDraw) {
for (int y = blockY; y < endRow && y < ctx->srcHeight; y++) {
const uint8_t* srcRow = ctx->mcuBuf.get() + (y - blockY) * ctx->srcWidth;
if (!ctx->needsScaling) {
if (ctx->smoothUpscale) {
processSmoothSourceRow(ctx, srcRow, y);
} else if (!ctx->needsScaling) {
// 1:1 — outWidth == srcWidth, write directly
writeOutputRow(ctx, srcRow, y);
} else {
@@ -472,6 +567,9 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(HalFile& jpegFile, Print& b
needsScaling = true;
}
const bool smoothUpscale =
progressiveDecode && needsScaling && scaleSrcWidth <= outWidth && scaleSrcHeight <= outHeight;
// Write BMP header with output dimensions
int bytesPerRow;
if (USE_8BIT_OUTPUT && !oneBit) {
@@ -496,6 +594,11 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(HalFile& jpegFile, Print& b
ctx.needsScaling = needsScaling;
ctx.scaleX_fp = scaleX_fp;
ctx.scaleY_fp = scaleY_fp;
ctx.smoothUpscale = smoothUpscale;
ctx.smoothScaleX_fp = interpolationStep(ctx.srcWidth, outWidth);
ctx.smoothScaleY_fp = interpolationStep(ctx.srcHeight, outHeight);
ctx.smoothNextOutY = 0;
ctx.smoothPrevY = -1;
ctx.error = false;
// MCU row buffer: MAX_MCU_HEIGHT rows × decoded srcWidth columns of grayscale
@@ -512,7 +615,20 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(HalFile& jpegFile, Print& b
return false;
}
if (needsScaling) {
if (smoothUpscale) {
// One contiguous allocation avoids three heap blocks while keeping smoothing line-buffered.
const size_t smoothRowsBytes = static_cast<size_t>(outWidth) * 3;
ctx.smoothRows = makeUniqueNoThrow<uint8_t[]>(smoothRowsBytes);
if (!ctx.smoothRows) {
LOG_ERR("JPG", "OOM: progressive smoothing buffers");
return false;
}
ctx.smoothPrevRow = ctx.smoothRows.get();
ctx.smoothCurrRow = ctx.smoothPrevRow + outWidth;
ctx.smoothOutRow = ctx.smoothCurrRow + outWidth;
LOG_DBG("JPG", "Progressive smoothing: %dx%d -> %dx%d, buffers=%u bytes", ctx.srcWidth, ctx.srcHeight, outWidth,
outHeight, static_cast<unsigned>(smoothRowsBytes));
} else if (needsScaling) {
ctx.rowAccum = makeUniqueNoThrow<uint32_t[]>(outWidth);
ctx.rowCount = makeUniqueNoThrow<uint32_t[]>(outWidth);
if (!ctx.rowAccum || !ctx.rowCount) {
@@ -549,6 +665,10 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(HalFile& jpegFile, Print& b
rc = jpeg->decode(0, 0, 0);
if (rc == 1 && ctx.smoothUpscale && !ctx.error) {
finishSmoothUpscale(&ctx);
}
if (rc != 1 || ctx.error) {
LOG_ERR("JPG", "JPEG decode failed (rc=%d, err=%d)", rc, jpeg->getLastError());
return false;
+184 -47
View File
@@ -30,8 +30,7 @@ int parseIndex(const std::string& xpath, const char* prefix, bool last = false)
int parseCharOffset(const std::string& xpath) {
const size_t textPos = xpath.rfind("text()");
if (textPos == std::string::npos) return 0;
const size_t dotPos = xpath.find('.', textPos);
const size_t dotPos = (textPos != std::string::npos) ? xpath.find('.', textPos) : xpath.rfind('.');
if (dotPos == std::string::npos || dotPos + 1 >= xpath.size()) return 0;
int val = 0;
for (size_t i = dotPos + 1; i < xpath.size(); i++) {
@@ -104,7 +103,13 @@ bool isChapterStartXPath(const std::string& xpath) {
if (dotPos == std::string::npos || dotPos <= bodyContentStart || dotPos + 1 >= xpath.size()) {
return false;
}
if (xpath.find('/', bodyContentStart) != std::string::npos) {
size_t terminalEnd = dotPos;
static constexpr char kTextNode[] = "/text()";
const size_t textNodePos = xpath.rfind(kTextNode, dotPos);
if (textNodePos != std::string::npos && textNodePos >= bodyContentStart) {
terminalEnd = textNodePos;
}
if (xpath.find('/', bodyContentStart) < terminalEnd) {
return false;
}
@@ -122,7 +127,7 @@ struct XPathStep {
static constexpr int MAX_XPATH_DEPTH = 16;
// Parse the XPath segment between /body/DocFragment[N]/body/ and text()[N].offset
// Parse the XPath segment between /body/DocFragment[N]/body/ and the terminal position
// into an ordered sequence of steps. Returns step count, 0 on failure.
// Example input: "/body/DocFragment[1]/body/div[1]/ul/li[4]/text()[1].51"
// Fills steps with: {div,1}, {ul,1}, {li,4}
@@ -136,13 +141,20 @@ int parseXPathSteps(const std::string& xpath, XPathStep steps[MAX_XPATH_DEPTH])
if (xpath.compare(afterBracket + 1, strlen(kBody), kBody) != 0) return 0;
size_t pos = afterBracket + 1 + strlen(kBody);
const size_t textPos = xpath.rfind("/text()");
if (textPos == std::string::npos || textPos <= pos) return 0;
size_t stepsEnd = xpath.rfind("/text()");
if (stepsEnd == std::string::npos) {
stepsEnd = xpath.rfind('.');
if (stepsEnd == std::string::npos || stepsEnd <= pos || stepsEnd + 1 >= xpath.size()) return 0;
for (size_t i = stepsEnd + 1; i < xpath.size(); i++) {
if (xpath[i] < '0' || xpath[i] > '9') return 0;
}
}
if (stepsEnd <= pos) return 0;
int count = 0;
while (pos < textPos && count < MAX_XPATH_DEPTH) {
while (pos < stepsEnd && count < MAX_XPATH_DEPTH) {
const size_t slash = xpath.find('/', pos);
const size_t segEnd = (slash < textPos) ? slash : textPos;
const size_t segEnd = (slash < stepsEnd) ? slash : stepsEnd;
XPathStep& step = steps[count];
const size_t bracket = xpath.find('[', pos);
@@ -166,7 +178,7 @@ int parseXPathSteps(const std::string& xpath, XPathStep steps[MAX_XPATH_DEPTH])
}
count++;
pos = (slash < textPos) ? slash + 1 : textPos;
pos = (slash < stepsEnd) ? slash + 1 : stepsEnd;
}
return count;
}
@@ -223,10 +235,148 @@ class ParagraphStreamer final : public Print {
char capturedAnchorId[MAX_ANCHOR_ID] = {};
int capturedAnchorIdLen = 0;
bool capturingAnchorTag = false;
enum IdScanState { ID_SCAN, ID_I, ID_D, ID_EQ, ID_IN_VALUE_D, ID_IN_VALUE_S } idState = ID_SCAN;
enum AnchorAttrState {
ATTR_FIND_NAME,
ATTR_READ_NAME,
ATTR_AFTER_NAME,
ATTR_BEFORE_VALUE,
ATTR_CAPTURE_D,
ATTR_CAPTURE_S
} attrState = ATTR_FIND_NAME;
uint8_t attrNameLen = 0;
bool currentAttrIsId = false;
bool inAttrQuote =
false; // true while inside a quoted attribute value (prevents '/' from being treated as self-close)
char attrQuoteChar = 0;
uint8_t nonVisibleDepth = 0;
bool isNonVisibleTag() const {
return strcasecmp(tagName, "head") == 0 || strcasecmp(tagName, "style") == 0 ||
strcasecmp(tagName, "script") == 0 || strcasecmp(tagName, "title") == 0;
}
static bool isAttrWhitespace(uint8_t c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r'; }
static bool isAttrNameChar(uint8_t c) {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-' ||
c == ':' || c == '.';
}
void resetAnchorAttrScan() {
attrState = ATTR_FIND_NAME;
attrNameLen = 0;
currentAttrIsId = false;
}
void finishCapturedAnchorId() {
capturedAnchorId[capturedAnchorIdLen] = '\0';
capturingAnchorTag = false;
resetAnchorAttrScan();
}
void beginAnchorIdScan() {
capturingAnchorTag = true;
resetAnchorAttrScan();
}
void endAnchorIdScan() {
if (capturingAnchorTag) {
capturedAnchorIdLen = 0;
}
capturingAnchorTag = false;
resetAnchorAttrScan();
}
void appendCapturedAnchorId(uint8_t c) {
if (capturedAnchorIdLen + 1 < MAX_ANCHOR_ID) {
capturedAnchorId[capturedAnchorIdLen++] = c;
}
}
void scanAnchorAttribute(uint8_t c) {
switch (attrState) {
case ATTR_FIND_NAME:
if (isAttrNameChar(c)) {
attrState = ATTR_READ_NAME;
attrNameLen = 1;
currentAttrIsId = c == 'i';
}
break;
case ATTR_READ_NAME:
if (isAttrNameChar(c)) {
if (attrNameLen == 1) {
currentAttrIsId = currentAttrIsId && c == 'd';
} else {
currentAttrIsId = false;
}
attrNameLen++;
} else {
currentAttrIsId = currentAttrIsId && attrNameLen == 2;
if (isAttrWhitespace(c)) {
attrState = ATTR_AFTER_NAME;
} else if (c == '=') {
attrState = ATTR_BEFORE_VALUE;
} else {
resetAnchorAttrScan();
}
}
break;
case ATTR_AFTER_NAME:
if (isAttrWhitespace(c)) {
break;
}
if (c == '=') {
attrState = ATTR_BEFORE_VALUE;
} else if (isAttrNameChar(c)) {
attrState = ATTR_READ_NAME;
attrNameLen = 1;
currentAttrIsId = c == 'i';
} else {
resetAnchorAttrScan();
}
break;
case ATTR_BEFORE_VALUE:
if (isAttrWhitespace(c)) {
break;
}
if (currentAttrIsId && c == '"') {
capturedAnchorIdLen = 0;
attrState = ATTR_CAPTURE_D;
} else if (currentAttrIsId && c == '\'') {
capturedAnchorIdLen = 0;
attrState = ATTR_CAPTURE_S;
} else if (c == '"') {
attrState = ATTR_CAPTURE_D;
} else if (c == '\'') {
attrState = ATTR_CAPTURE_S;
} else {
resetAnchorAttrScan();
}
break;
case ATTR_CAPTURE_D:
if (c == '"') {
if (currentAttrIsId) {
finishCapturedAnchorId();
} else {
resetAnchorAttrScan();
}
} else if (currentAttrIsId) {
appendCapturedAnchorId(c);
}
break;
case ATTR_CAPTURE_S:
if (c == '\'') {
if (currentAttrIsId) {
finishCapturedAnchorId();
} else {
resetAnchorAttrScan();
}
} else if (currentAttrIsId) {
appendCapturedAnchorId(c);
}
break;
}
}
void onVisibleCodepoint() {
totalVisChars++;
@@ -286,15 +436,19 @@ class ParagraphStreamer final : public Print {
void onOpenTag() {
htmlDepth++;
if (nonVisibleDepth > 0 || isNonVisibleTag()) {
nonVisibleDepth++;
return;
}
if (stepCount == 0) {
if (strcasecmp(tagName, "p") == 0) onLegacyP();
return;
}
// Capture <a id> inside the fully-matched element even after target char is found
// Capture a child <a id> inside the fully-matched element even after target char is found.
if (revPFound && matchedDepth == stepCount && capturedAnchorIdLen == 0 && strcasecmp(tagName, "a") == 0) {
capturingAnchorTag = true;
idState = ID_SCAN;
beginAnchorIdScan();
}
if (revDone) return;
@@ -315,6 +469,7 @@ class ParagraphStreamer final : public Print {
stepEnteredAtDepth[matchedDepth] = htmlDepth;
matchedDepth++;
if (matchedDepth == stepCount) {
beginAnchorIdScan();
paragraphAtMatch = pCount;
liCountAtMatch = liCount;
revPFound = true;
@@ -332,6 +487,12 @@ class ParagraphStreamer final : public Print {
}
void onCloseTag() {
if (nonVisibleDepth > 0) {
nonVisibleDepth--;
if (htmlDepth > 0) htmlDepth--;
return;
}
// Legacy mode: each direct child element closing advances the text node index.
if (stepCount == 0 && revPFound && !revDone && paragraphHtmlDepth >= 0 && htmlDepth == paragraphHtmlDepth + 1) {
currentTextNode++;
@@ -419,42 +580,12 @@ class ParagraphStreamer final : public Print {
attrQuoteChar = 0;
}
if (capturingAnchorTag) {
switch (idState) {
case ID_SCAN:
idState = (c == 'i' || c == 'I') ? ID_I : ID_SCAN;
break;
case ID_I:
idState = (c == 'd' || c == 'D') ? ID_D : ID_SCAN;
break;
case ID_D:
idState = (c == '=') ? ID_EQ : ID_SCAN;
break;
case ID_EQ:
if (c == '"')
idState = ID_IN_VALUE_D;
else if (c == '\'')
idState = ID_IN_VALUE_S;
break;
case ID_IN_VALUE_D:
if (c == '"') {
capturedAnchorId[capturedAnchorIdLen] = '\0';
capturingAnchorTag = false;
} else if (capturedAnchorIdLen + 1 < MAX_ANCHOR_ID)
capturedAnchorId[capturedAnchorIdLen++] = c;
break;
case ID_IN_VALUE_S:
if (c == '\'') {
capturedAnchorId[capturedAnchorIdLen] = '\0';
capturingAnchorTag = false;
} else if (capturedAnchorIdLen + 1 < MAX_ANCHOR_ID)
capturedAnchorId[capturedAnchorIdLen++] = c;
break;
}
scanAnchorAttribute(c);
}
// Only treat '/' as self-closing when outside a quoted attribute value.
if (c == '/' && !inAttrQuote) {
endAnchorIdScan();
onCloseTag();
capturingAnchorTag = false;
}
break;
}
@@ -512,10 +643,13 @@ class ParagraphStreamer final : public Print {
tagNameLen = 0;
tagIsClose = false;
capturingAnchorTag = false;
idState = ID_SCAN;
resetAnchorAttrScan();
inAttrQuote = false;
attrQuoteChar = 0;
} else if (c == '>') {
if (tagState == TAG_ATTRS) {
endAnchorIdScan();
}
globalInTag = false;
inAttrQuote = false;
if (tagState == TAG_IN_NAME && tagNameLen > 0) {
@@ -529,6 +663,9 @@ class ParagraphStreamer final : public Print {
tagState = TAG_IDLE;
} else if (globalInTag) {
processByteInTag(c);
} else if (nonVisibleDepth > 0) {
// Ignore head/style/script/title text. KOReader XPaths are body-relative, and CSS text
// should not contribute to intra-spine progress.
} else {
if (c == '&') {
globalInEntity = true;
@@ -762,4 +899,4 @@ std::string ProgressMapper::generateXPath(const std::shared_ptr<Epub>& epub, int
const int p = s.paragraphCount();
return (p > 0) ? base + "/p[" + std::to_string(p) + "]" : base;
}
}
+68
View File
@@ -1,5 +1,73 @@
#include "Utf8.h"
#include "Utf8ComposeTable.h"
namespace {
// Look up the canonical composition of (base + combining mark), or 0 if none.
uint32_t utf8ComposePair(const uint32_t base, const uint32_t mark) {
if (base > 0xFFFF || mark > 0xFFFF) return 0;
int lo = 0;
int hi = kUtf8ComposeTableSize - 1;
while (lo <= hi) {
const int mid = (lo + hi) / 2;
const Utf8ComposeEntry& e = kUtf8ComposeTable[mid];
if (e.base < base || (e.base == base && e.mark < mark)) {
lo = mid + 1;
} else if (e.base > base || (e.base == base && e.mark > mark)) {
hi = mid - 1;
} else {
return e.composed;
}
}
return 0;
}
} // namespace
std::string utf8ComposeNfc(const std::string& in) {
// Fast path: NFC composition can only change text that contains a combining
// diacritical mark U+0300-036F (UTF-8 lead byte 0xCC or 0xCD). Plain ASCII and
// already-precomposed (NFC) text -- the vast majority of words -- have none, so
// return them untouched without walking codepoints or allocating. A 0xCD that is
// actually a non-combining codepoint just falls through to the full pass below.
bool maybeHasMarks = false;
for (const unsigned char c : in) {
if (c == 0xCC || c == 0xCD) {
maybeHasMarks = true;
break;
}
}
if (!maybeHasMarks) return in;
std::string out;
out.reserve(in.size());
const unsigned char* p = reinterpret_cast<const unsigned char*>(in.c_str());
uint32_t base = 0;
bool haveBase = false;
while (*p) {
const uint32_t cp = utf8NextCodepoint(&p);
if (cp == 0) break;
if (utf8IsCombiningMark(cp)) {
const uint32_t composed = haveBase ? utf8ComposePair(base, cp) : 0;
if (composed) {
base = composed; // keep accumulating further marks onto the composed char
continue;
}
// No composition: flush the pending base, then emit the mark unchanged.
if (haveBase) {
utf8AppendCodepoint(base, out);
haveBase = false;
}
utf8AppendCodepoint(cp, out);
} else {
if (haveBase) utf8AppendCodepoint(base, out);
base = cp;
haveBase = true;
}
}
if (haveBase) utf8AppendCodepoint(base, out);
return out;
}
int utf8CodepointLen(const unsigned char c) {
if (c < 0x80) return 1; // 0xxxxxxx
if ((c >> 5) == 0x6) return 2; // 110xxxxx
+10 -1
View File
@@ -12,6 +12,12 @@ size_t utf8RemoveLastChar(std::string& str);
// Truncate string by removing N UTF-8 codepoints from the end.
void utf8TruncateChars(std::string& str, size_t numChars);
// Canonical composition (NFC) for the Latin / Vietnamese range: precomposes a
// base letter followed by combining diacritical mark(s) into a single codepoint.
// Needed because the device fonts have no combining-mark positioning, so text
// stored in NFD (e.g. some EPUB chapter titles) otherwise renders broken.
std::string utf8ComposeNfc(const std::string& in);
// Truncate a raw char buffer to the last complete UTF-8 codepoint boundary.
// Returns the new length (<= len). If the buffer ends mid-sequence, the
// incomplete trailing bytes are excluded.
@@ -21,12 +27,15 @@ int utf8SafeTruncateBuffer(const char* buf, int len);
// Covers CJK Unified Ideographs, Hiragana, Katakana, Hangul Syllables, CJK punctuation,
// and fullwidth forms — the ranges where word boundaries are implicit per character.
inline bool utf8IsCjkBreakable(const uint32_t cp) {
return (cp >= 0x3000 && cp <= 0x303F) // CJK Symbols and Punctuation
return (cp >= 0x1100 && cp <= 0x11FF) // Hangul Jamo
|| (cp >= 0x3000 && cp <= 0x303F) // CJK Symbols and Punctuation
|| (cp >= 0x3040 && cp <= 0x309F) // Hiragana
|| (cp >= 0x30A0 && cp <= 0x30FF) // Katakana
|| (cp >= 0x3130 && cp <= 0x318F) // Hangul Compatibility Jamo
|| (cp >= 0x3400 && cp <= 0x4DBF) // CJK Extension A
|| (cp >= 0x4E00 && cp <= 0x9FFF) // CJK Unified Ideographs
|| (cp >= 0xAC00 && cp <= 0xD7AF) // Hangul Syllables
|| (cp >= 0xD7B0 && cp <= 0xD7FF) // Hangul Jamo Extended-B
|| (cp >= 0xF900 && cp <= 0xFAFF) // CJK Compatibility Ideographs
|| (cp >= 0xFE30 && cp <= 0xFE4F) // CJK Compatibility Forms
|| (cp >= 0xFF01 && cp <= 0xFF60) // Fullwidth Latin / Punctuation
+229
View File
@@ -0,0 +1,229 @@
// Auto-generated canonical composition (NFC) table for the Latin / Vietnamese
// range (combining marks U+0300-U+036F). Generated from Python unicodedata.
// Used by utf8ComposeNfc() to precompose decomposed (NFD) text so the device
// fonts (which lack combining-mark positioning) render it correctly.
#pragma once
#include <cstdint>
struct Utf8ComposeEntry {
uint16_t base;
uint16_t mark;
uint16_t composed;
};
// Sorted by (base, mark) for binary search.
static constexpr Utf8ComposeEntry kUtf8ComposeTable[] = {
{0x0041, 0x0300, 0x00C0}, {0x0041, 0x0301, 0x00C1}, {0x0041, 0x0302, 0x00C2}, {0x0041, 0x0303, 0x00C3},
{0x0041, 0x0304, 0x0100}, {0x0041, 0x0306, 0x0102}, {0x0041, 0x0307, 0x0226}, {0x0041, 0x0308, 0x00C4},
{0x0041, 0x0309, 0x1EA2}, {0x0041, 0x030A, 0x00C5}, {0x0041, 0x030C, 0x01CD}, {0x0041, 0x030F, 0x0200},
{0x0041, 0x0311, 0x0202}, {0x0041, 0x0323, 0x1EA0}, {0x0041, 0x0325, 0x1E00}, {0x0041, 0x0328, 0x0104},
{0x0041, 0x0340, 0x00C0}, {0x0041, 0x0341, 0x00C1}, {0x0042, 0x0307, 0x1E02}, {0x0042, 0x0323, 0x1E04},
{0x0042, 0x0331, 0x1E06}, {0x0043, 0x0301, 0x0106}, {0x0043, 0x0302, 0x0108}, {0x0043, 0x0307, 0x010A},
{0x0043, 0x030C, 0x010C}, {0x0043, 0x0327, 0x00C7}, {0x0043, 0x0341, 0x0106}, {0x0044, 0x0307, 0x1E0A},
{0x0044, 0x030C, 0x010E}, {0x0044, 0x0323, 0x1E0C}, {0x0044, 0x0327, 0x1E10}, {0x0044, 0x032D, 0x1E12},
{0x0044, 0x0331, 0x1E0E}, {0x0045, 0x0300, 0x00C8}, {0x0045, 0x0301, 0x00C9}, {0x0045, 0x0302, 0x00CA},
{0x0045, 0x0303, 0x1EBC}, {0x0045, 0x0304, 0x0112}, {0x0045, 0x0306, 0x0114}, {0x0045, 0x0307, 0x0116},
{0x0045, 0x0308, 0x00CB}, {0x0045, 0x0309, 0x1EBA}, {0x0045, 0x030C, 0x011A}, {0x0045, 0x030F, 0x0204},
{0x0045, 0x0311, 0x0206}, {0x0045, 0x0323, 0x1EB8}, {0x0045, 0x0327, 0x0228}, {0x0045, 0x0328, 0x0118},
{0x0045, 0x032D, 0x1E18}, {0x0045, 0x0330, 0x1E1A}, {0x0045, 0x0340, 0x00C8}, {0x0045, 0x0341, 0x00C9},
{0x0046, 0x0307, 0x1E1E}, {0x0047, 0x0301, 0x01F4}, {0x0047, 0x0302, 0x011C}, {0x0047, 0x0304, 0x1E20},
{0x0047, 0x0306, 0x011E}, {0x0047, 0x0307, 0x0120}, {0x0047, 0x030C, 0x01E6}, {0x0047, 0x0327, 0x0122},
{0x0047, 0x0341, 0x01F4}, {0x0048, 0x0302, 0x0124}, {0x0048, 0x0307, 0x1E22}, {0x0048, 0x0308, 0x1E26},
{0x0048, 0x030C, 0x021E}, {0x0048, 0x0323, 0x1E24}, {0x0048, 0x0327, 0x1E28}, {0x0048, 0x032E, 0x1E2A},
{0x0049, 0x0300, 0x00CC}, {0x0049, 0x0301, 0x00CD}, {0x0049, 0x0302, 0x00CE}, {0x0049, 0x0303, 0x0128},
{0x0049, 0x0304, 0x012A}, {0x0049, 0x0306, 0x012C}, {0x0049, 0x0307, 0x0130}, {0x0049, 0x0308, 0x00CF},
{0x0049, 0x0309, 0x1EC8}, {0x0049, 0x030C, 0x01CF}, {0x0049, 0x030F, 0x0208}, {0x0049, 0x0311, 0x020A},
{0x0049, 0x0323, 0x1ECA}, {0x0049, 0x0328, 0x012E}, {0x0049, 0x0330, 0x1E2C}, {0x0049, 0x0340, 0x00CC},
{0x0049, 0x0341, 0x00CD}, {0x0049, 0x0344, 0x1E2E}, {0x004A, 0x0302, 0x0134}, {0x004B, 0x0301, 0x1E30},
{0x004B, 0x030C, 0x01E8}, {0x004B, 0x0323, 0x1E32}, {0x004B, 0x0327, 0x0136}, {0x004B, 0x0331, 0x1E34},
{0x004B, 0x0341, 0x1E30}, {0x004C, 0x0301, 0x0139}, {0x004C, 0x030C, 0x013D}, {0x004C, 0x0323, 0x1E36},
{0x004C, 0x0327, 0x013B}, {0x004C, 0x032D, 0x1E3C}, {0x004C, 0x0331, 0x1E3A}, {0x004C, 0x0341, 0x0139},
{0x004D, 0x0301, 0x1E3E}, {0x004D, 0x0307, 0x1E40}, {0x004D, 0x0323, 0x1E42}, {0x004D, 0x0341, 0x1E3E},
{0x004E, 0x0300, 0x01F8}, {0x004E, 0x0301, 0x0143}, {0x004E, 0x0303, 0x00D1}, {0x004E, 0x0307, 0x1E44},
{0x004E, 0x030C, 0x0147}, {0x004E, 0x0323, 0x1E46}, {0x004E, 0x0327, 0x0145}, {0x004E, 0x032D, 0x1E4A},
{0x004E, 0x0331, 0x1E48}, {0x004E, 0x0340, 0x01F8}, {0x004E, 0x0341, 0x0143}, {0x004F, 0x0300, 0x00D2},
{0x004F, 0x0301, 0x00D3}, {0x004F, 0x0302, 0x00D4}, {0x004F, 0x0303, 0x00D5}, {0x004F, 0x0304, 0x014C},
{0x004F, 0x0306, 0x014E}, {0x004F, 0x0307, 0x022E}, {0x004F, 0x0308, 0x00D6}, {0x004F, 0x0309, 0x1ECE},
{0x004F, 0x030B, 0x0150}, {0x004F, 0x030C, 0x01D1}, {0x004F, 0x030F, 0x020C}, {0x004F, 0x0311, 0x020E},
{0x004F, 0x031B, 0x01A0}, {0x004F, 0x0323, 0x1ECC}, {0x004F, 0x0328, 0x01EA}, {0x004F, 0x0340, 0x00D2},
{0x004F, 0x0341, 0x00D3}, {0x0050, 0x0301, 0x1E54}, {0x0050, 0x0307, 0x1E56}, {0x0050, 0x0341, 0x1E54},
{0x0052, 0x0301, 0x0154}, {0x0052, 0x0307, 0x1E58}, {0x0052, 0x030C, 0x0158}, {0x0052, 0x030F, 0x0210},
{0x0052, 0x0311, 0x0212}, {0x0052, 0x0323, 0x1E5A}, {0x0052, 0x0327, 0x0156}, {0x0052, 0x0331, 0x1E5E},
{0x0052, 0x0341, 0x0154}, {0x0053, 0x0301, 0x015A}, {0x0053, 0x0302, 0x015C}, {0x0053, 0x0307, 0x1E60},
{0x0053, 0x030C, 0x0160}, {0x0053, 0x0323, 0x1E62}, {0x0053, 0x0326, 0x0218}, {0x0053, 0x0327, 0x015E},
{0x0053, 0x0341, 0x015A}, {0x0054, 0x0307, 0x1E6A}, {0x0054, 0x030C, 0x0164}, {0x0054, 0x0323, 0x1E6C},
{0x0054, 0x0326, 0x021A}, {0x0054, 0x0327, 0x0162}, {0x0054, 0x032D, 0x1E70}, {0x0054, 0x0331, 0x1E6E},
{0x0055, 0x0300, 0x00D9}, {0x0055, 0x0301, 0x00DA}, {0x0055, 0x0302, 0x00DB}, {0x0055, 0x0303, 0x0168},
{0x0055, 0x0304, 0x016A}, {0x0055, 0x0306, 0x016C}, {0x0055, 0x0308, 0x00DC}, {0x0055, 0x0309, 0x1EE6},
{0x0055, 0x030A, 0x016E}, {0x0055, 0x030B, 0x0170}, {0x0055, 0x030C, 0x01D3}, {0x0055, 0x030F, 0x0214},
{0x0055, 0x0311, 0x0216}, {0x0055, 0x031B, 0x01AF}, {0x0055, 0x0323, 0x1EE4}, {0x0055, 0x0324, 0x1E72},
{0x0055, 0x0328, 0x0172}, {0x0055, 0x032D, 0x1E76}, {0x0055, 0x0330, 0x1E74}, {0x0055, 0x0340, 0x00D9},
{0x0055, 0x0341, 0x00DA}, {0x0055, 0x0344, 0x01D7}, {0x0056, 0x0303, 0x1E7C}, {0x0056, 0x0323, 0x1E7E},
{0x0057, 0x0300, 0x1E80}, {0x0057, 0x0301, 0x1E82}, {0x0057, 0x0302, 0x0174}, {0x0057, 0x0307, 0x1E86},
{0x0057, 0x0308, 0x1E84}, {0x0057, 0x0323, 0x1E88}, {0x0057, 0x0340, 0x1E80}, {0x0057, 0x0341, 0x1E82},
{0x0058, 0x0307, 0x1E8A}, {0x0058, 0x0308, 0x1E8C}, {0x0059, 0x0300, 0x1EF2}, {0x0059, 0x0301, 0x00DD},
{0x0059, 0x0302, 0x0176}, {0x0059, 0x0303, 0x1EF8}, {0x0059, 0x0304, 0x0232}, {0x0059, 0x0307, 0x1E8E},
{0x0059, 0x0308, 0x0178}, {0x0059, 0x0309, 0x1EF6}, {0x0059, 0x0323, 0x1EF4}, {0x0059, 0x0340, 0x1EF2},
{0x0059, 0x0341, 0x00DD}, {0x005A, 0x0301, 0x0179}, {0x005A, 0x0302, 0x1E90}, {0x005A, 0x0307, 0x017B},
{0x005A, 0x030C, 0x017D}, {0x005A, 0x0323, 0x1E92}, {0x005A, 0x0331, 0x1E94}, {0x005A, 0x0341, 0x0179},
{0x0061, 0x0300, 0x00E0}, {0x0061, 0x0301, 0x00E1}, {0x0061, 0x0302, 0x00E2}, {0x0061, 0x0303, 0x00E3},
{0x0061, 0x0304, 0x0101}, {0x0061, 0x0306, 0x0103}, {0x0061, 0x0307, 0x0227}, {0x0061, 0x0308, 0x00E4},
{0x0061, 0x0309, 0x1EA3}, {0x0061, 0x030A, 0x00E5}, {0x0061, 0x030C, 0x01CE}, {0x0061, 0x030F, 0x0201},
{0x0061, 0x0311, 0x0203}, {0x0061, 0x0323, 0x1EA1}, {0x0061, 0x0325, 0x1E01}, {0x0061, 0x0328, 0x0105},
{0x0061, 0x0340, 0x00E0}, {0x0061, 0x0341, 0x00E1}, {0x0062, 0x0307, 0x1E03}, {0x0062, 0x0323, 0x1E05},
{0x0062, 0x0331, 0x1E07}, {0x0063, 0x0301, 0x0107}, {0x0063, 0x0302, 0x0109}, {0x0063, 0x0307, 0x010B},
{0x0063, 0x030C, 0x010D}, {0x0063, 0x0327, 0x00E7}, {0x0063, 0x0341, 0x0107}, {0x0064, 0x0307, 0x1E0B},
{0x0064, 0x030C, 0x010F}, {0x0064, 0x0323, 0x1E0D}, {0x0064, 0x0327, 0x1E11}, {0x0064, 0x032D, 0x1E13},
{0x0064, 0x0331, 0x1E0F}, {0x0065, 0x0300, 0x00E8}, {0x0065, 0x0301, 0x00E9}, {0x0065, 0x0302, 0x00EA},
{0x0065, 0x0303, 0x1EBD}, {0x0065, 0x0304, 0x0113}, {0x0065, 0x0306, 0x0115}, {0x0065, 0x0307, 0x0117},
{0x0065, 0x0308, 0x00EB}, {0x0065, 0x0309, 0x1EBB}, {0x0065, 0x030C, 0x011B}, {0x0065, 0x030F, 0x0205},
{0x0065, 0x0311, 0x0207}, {0x0065, 0x0323, 0x1EB9}, {0x0065, 0x0327, 0x0229}, {0x0065, 0x0328, 0x0119},
{0x0065, 0x032D, 0x1E19}, {0x0065, 0x0330, 0x1E1B}, {0x0065, 0x0340, 0x00E8}, {0x0065, 0x0341, 0x00E9},
{0x0066, 0x0307, 0x1E1F}, {0x0067, 0x0301, 0x01F5}, {0x0067, 0x0302, 0x011D}, {0x0067, 0x0304, 0x1E21},
{0x0067, 0x0306, 0x011F}, {0x0067, 0x0307, 0x0121}, {0x0067, 0x030C, 0x01E7}, {0x0067, 0x0327, 0x0123},
{0x0067, 0x0341, 0x01F5}, {0x0068, 0x0302, 0x0125}, {0x0068, 0x0307, 0x1E23}, {0x0068, 0x0308, 0x1E27},
{0x0068, 0x030C, 0x021F}, {0x0068, 0x0323, 0x1E25}, {0x0068, 0x0327, 0x1E29}, {0x0068, 0x032E, 0x1E2B},
{0x0068, 0x0331, 0x1E96}, {0x0069, 0x0300, 0x00EC}, {0x0069, 0x0301, 0x00ED}, {0x0069, 0x0302, 0x00EE},
{0x0069, 0x0303, 0x0129}, {0x0069, 0x0304, 0x012B}, {0x0069, 0x0306, 0x012D}, {0x0069, 0x0308, 0x00EF},
{0x0069, 0x0309, 0x1EC9}, {0x0069, 0x030C, 0x01D0}, {0x0069, 0x030F, 0x0209}, {0x0069, 0x0311, 0x020B},
{0x0069, 0x0323, 0x1ECB}, {0x0069, 0x0328, 0x012F}, {0x0069, 0x0330, 0x1E2D}, {0x0069, 0x0340, 0x00EC},
{0x0069, 0x0341, 0x00ED}, {0x0069, 0x0344, 0x1E2F}, {0x006A, 0x0302, 0x0135}, {0x006A, 0x030C, 0x01F0},
{0x006B, 0x0301, 0x1E31}, {0x006B, 0x030C, 0x01E9}, {0x006B, 0x0323, 0x1E33}, {0x006B, 0x0327, 0x0137},
{0x006B, 0x0331, 0x1E35}, {0x006B, 0x0341, 0x1E31}, {0x006C, 0x0301, 0x013A}, {0x006C, 0x030C, 0x013E},
{0x006C, 0x0323, 0x1E37}, {0x006C, 0x0327, 0x013C}, {0x006C, 0x032D, 0x1E3D}, {0x006C, 0x0331, 0x1E3B},
{0x006C, 0x0341, 0x013A}, {0x006D, 0x0301, 0x1E3F}, {0x006D, 0x0307, 0x1E41}, {0x006D, 0x0323, 0x1E43},
{0x006D, 0x0341, 0x1E3F}, {0x006E, 0x0300, 0x01F9}, {0x006E, 0x0301, 0x0144}, {0x006E, 0x0303, 0x00F1},
{0x006E, 0x0307, 0x1E45}, {0x006E, 0x030C, 0x0148}, {0x006E, 0x0323, 0x1E47}, {0x006E, 0x0327, 0x0146},
{0x006E, 0x032D, 0x1E4B}, {0x006E, 0x0331, 0x1E49}, {0x006E, 0x0340, 0x01F9}, {0x006E, 0x0341, 0x0144},
{0x006F, 0x0300, 0x00F2}, {0x006F, 0x0301, 0x00F3}, {0x006F, 0x0302, 0x00F4}, {0x006F, 0x0303, 0x00F5},
{0x006F, 0x0304, 0x014D}, {0x006F, 0x0306, 0x014F}, {0x006F, 0x0307, 0x022F}, {0x006F, 0x0308, 0x00F6},
{0x006F, 0x0309, 0x1ECF}, {0x006F, 0x030B, 0x0151}, {0x006F, 0x030C, 0x01D2}, {0x006F, 0x030F, 0x020D},
{0x006F, 0x0311, 0x020F}, {0x006F, 0x031B, 0x01A1}, {0x006F, 0x0323, 0x1ECD}, {0x006F, 0x0328, 0x01EB},
{0x006F, 0x0340, 0x00F2}, {0x006F, 0x0341, 0x00F3}, {0x0070, 0x0301, 0x1E55}, {0x0070, 0x0307, 0x1E57},
{0x0070, 0x0341, 0x1E55}, {0x0072, 0x0301, 0x0155}, {0x0072, 0x0307, 0x1E59}, {0x0072, 0x030C, 0x0159},
{0x0072, 0x030F, 0x0211}, {0x0072, 0x0311, 0x0213}, {0x0072, 0x0323, 0x1E5B}, {0x0072, 0x0327, 0x0157},
{0x0072, 0x0331, 0x1E5F}, {0x0072, 0x0341, 0x0155}, {0x0073, 0x0301, 0x015B}, {0x0073, 0x0302, 0x015D},
{0x0073, 0x0307, 0x1E61}, {0x0073, 0x030C, 0x0161}, {0x0073, 0x0323, 0x1E63}, {0x0073, 0x0326, 0x0219},
{0x0073, 0x0327, 0x015F}, {0x0073, 0x0341, 0x015B}, {0x0074, 0x0307, 0x1E6B}, {0x0074, 0x0308, 0x1E97},
{0x0074, 0x030C, 0x0165}, {0x0074, 0x0323, 0x1E6D}, {0x0074, 0x0326, 0x021B}, {0x0074, 0x0327, 0x0163},
{0x0074, 0x032D, 0x1E71}, {0x0074, 0x0331, 0x1E6F}, {0x0075, 0x0300, 0x00F9}, {0x0075, 0x0301, 0x00FA},
{0x0075, 0x0302, 0x00FB}, {0x0075, 0x0303, 0x0169}, {0x0075, 0x0304, 0x016B}, {0x0075, 0x0306, 0x016D},
{0x0075, 0x0308, 0x00FC}, {0x0075, 0x0309, 0x1EE7}, {0x0075, 0x030A, 0x016F}, {0x0075, 0x030B, 0x0171},
{0x0075, 0x030C, 0x01D4}, {0x0075, 0x030F, 0x0215}, {0x0075, 0x0311, 0x0217}, {0x0075, 0x031B, 0x01B0},
{0x0075, 0x0323, 0x1EE5}, {0x0075, 0x0324, 0x1E73}, {0x0075, 0x0328, 0x0173}, {0x0075, 0x032D, 0x1E77},
{0x0075, 0x0330, 0x1E75}, {0x0075, 0x0340, 0x00F9}, {0x0075, 0x0341, 0x00FA}, {0x0075, 0x0344, 0x01D8},
{0x0076, 0x0303, 0x1E7D}, {0x0076, 0x0323, 0x1E7F}, {0x0077, 0x0300, 0x1E81}, {0x0077, 0x0301, 0x1E83},
{0x0077, 0x0302, 0x0175}, {0x0077, 0x0307, 0x1E87}, {0x0077, 0x0308, 0x1E85}, {0x0077, 0x030A, 0x1E98},
{0x0077, 0x0323, 0x1E89}, {0x0077, 0x0340, 0x1E81}, {0x0077, 0x0341, 0x1E83}, {0x0078, 0x0307, 0x1E8B},
{0x0078, 0x0308, 0x1E8D}, {0x0079, 0x0300, 0x1EF3}, {0x0079, 0x0301, 0x00FD}, {0x0079, 0x0302, 0x0177},
{0x0079, 0x0303, 0x1EF9}, {0x0079, 0x0304, 0x0233}, {0x0079, 0x0307, 0x1E8F}, {0x0079, 0x0308, 0x00FF},
{0x0079, 0x0309, 0x1EF7}, {0x0079, 0x030A, 0x1E99}, {0x0079, 0x0323, 0x1EF5}, {0x0079, 0x0340, 0x1EF3},
{0x0079, 0x0341, 0x00FD}, {0x007A, 0x0301, 0x017A}, {0x007A, 0x0302, 0x1E91}, {0x007A, 0x0307, 0x017C},
{0x007A, 0x030C, 0x017E}, {0x007A, 0x0323, 0x1E93}, {0x007A, 0x0331, 0x1E95}, {0x007A, 0x0341, 0x017A},
{0x00A8, 0x0300, 0x1FED}, {0x00A8, 0x0301, 0x0385}, {0x00A8, 0x0340, 0x1FED}, {0x00A8, 0x0341, 0x0385},
{0x00A8, 0x0342, 0x1FC1}, {0x00C2, 0x0300, 0x1EA6}, {0x00C2, 0x0301, 0x1EA4}, {0x00C2, 0x0303, 0x1EAA},
{0x00C2, 0x0309, 0x1EA8}, {0x00C2, 0x0323, 0x1EAC}, {0x00C2, 0x0340, 0x1EA6}, {0x00C2, 0x0341, 0x1EA4},
{0x00C4, 0x0304, 0x01DE}, {0x00C5, 0x0301, 0x01FA}, {0x00C5, 0x0341, 0x01FA}, {0x00C6, 0x0301, 0x01FC},
{0x00C6, 0x0304, 0x01E2}, {0x00C6, 0x0341, 0x01FC}, {0x00C7, 0x0301, 0x1E08}, {0x00C7, 0x0341, 0x1E08},
{0x00CA, 0x0300, 0x1EC0}, {0x00CA, 0x0301, 0x1EBE}, {0x00CA, 0x0303, 0x1EC4}, {0x00CA, 0x0309, 0x1EC2},
{0x00CA, 0x0323, 0x1EC6}, {0x00CA, 0x0340, 0x1EC0}, {0x00CA, 0x0341, 0x1EBE}, {0x00CF, 0x0301, 0x1E2E},
{0x00CF, 0x0341, 0x1E2E}, {0x00D2, 0x031B, 0x1EDC}, {0x00D3, 0x031B, 0x1EDA}, {0x00D4, 0x0300, 0x1ED2},
{0x00D4, 0x0301, 0x1ED0}, {0x00D4, 0x0303, 0x1ED6}, {0x00D4, 0x0309, 0x1ED4}, {0x00D4, 0x0323, 0x1ED8},
{0x00D4, 0x0340, 0x1ED2}, {0x00D4, 0x0341, 0x1ED0}, {0x00D5, 0x0301, 0x1E4C}, {0x00D5, 0x0304, 0x022C},
{0x00D5, 0x0308, 0x1E4E}, {0x00D5, 0x031B, 0x1EE0}, {0x00D5, 0x0341, 0x1E4C}, {0x00D6, 0x0304, 0x022A},
{0x00D8, 0x0301, 0x01FE}, {0x00D8, 0x0341, 0x01FE}, {0x00D9, 0x031B, 0x1EEA}, {0x00DA, 0x031B, 0x1EE8},
{0x00DC, 0x0300, 0x01DB}, {0x00DC, 0x0301, 0x01D7}, {0x00DC, 0x0304, 0x01D5}, {0x00DC, 0x030C, 0x01D9},
{0x00DC, 0x0340, 0x01DB}, {0x00DC, 0x0341, 0x01D7}, {0x00E2, 0x0300, 0x1EA7}, {0x00E2, 0x0301, 0x1EA5},
{0x00E2, 0x0303, 0x1EAB}, {0x00E2, 0x0309, 0x1EA9}, {0x00E2, 0x0323, 0x1EAD}, {0x00E2, 0x0340, 0x1EA7},
{0x00E2, 0x0341, 0x1EA5}, {0x00E4, 0x0304, 0x01DF}, {0x00E5, 0x0301, 0x01FB}, {0x00E5, 0x0341, 0x01FB},
{0x00E6, 0x0301, 0x01FD}, {0x00E6, 0x0304, 0x01E3}, {0x00E6, 0x0341, 0x01FD}, {0x00E7, 0x0301, 0x1E09},
{0x00E7, 0x0341, 0x1E09}, {0x00EA, 0x0300, 0x1EC1}, {0x00EA, 0x0301, 0x1EBF}, {0x00EA, 0x0303, 0x1EC5},
{0x00EA, 0x0309, 0x1EC3}, {0x00EA, 0x0323, 0x1EC7}, {0x00EA, 0x0340, 0x1EC1}, {0x00EA, 0x0341, 0x1EBF},
{0x00EF, 0x0301, 0x1E2F}, {0x00EF, 0x0341, 0x1E2F}, {0x00F2, 0x031B, 0x1EDD}, {0x00F3, 0x031B, 0x1EDB},
{0x00F4, 0x0300, 0x1ED3}, {0x00F4, 0x0301, 0x1ED1}, {0x00F4, 0x0303, 0x1ED7}, {0x00F4, 0x0309, 0x1ED5},
{0x00F4, 0x0323, 0x1ED9}, {0x00F4, 0x0340, 0x1ED3}, {0x00F4, 0x0341, 0x1ED1}, {0x00F5, 0x0301, 0x1E4D},
{0x00F5, 0x0304, 0x022D}, {0x00F5, 0x0308, 0x1E4F}, {0x00F5, 0x031B, 0x1EE1}, {0x00F5, 0x0341, 0x1E4D},
{0x00F6, 0x0304, 0x022B}, {0x00F8, 0x0301, 0x01FF}, {0x00F8, 0x0341, 0x01FF}, {0x00F9, 0x031B, 0x1EEB},
{0x00FA, 0x031B, 0x1EE9}, {0x00FC, 0x0300, 0x01DC}, {0x00FC, 0x0301, 0x01D8}, {0x00FC, 0x0304, 0x01D6},
{0x00FC, 0x030C, 0x01DA}, {0x00FC, 0x0340, 0x01DC}, {0x00FC, 0x0341, 0x01D8}, {0x0102, 0x0300, 0x1EB0},
{0x0102, 0x0301, 0x1EAE}, {0x0102, 0x0303, 0x1EB4}, {0x0102, 0x0309, 0x1EB2}, {0x0102, 0x0323, 0x1EB6},
{0x0102, 0x0340, 0x1EB0}, {0x0102, 0x0341, 0x1EAE}, {0x0103, 0x0300, 0x1EB1}, {0x0103, 0x0301, 0x1EAF},
{0x0103, 0x0303, 0x1EB5}, {0x0103, 0x0309, 0x1EB3}, {0x0103, 0x0323, 0x1EB7}, {0x0103, 0x0340, 0x1EB1},
{0x0103, 0x0341, 0x1EAF}, {0x0106, 0x0327, 0x1E08}, {0x0107, 0x0327, 0x1E09}, {0x0112, 0x0300, 0x1E14},
{0x0112, 0x0301, 0x1E16}, {0x0112, 0x0340, 0x1E14}, {0x0112, 0x0341, 0x1E16}, {0x0113, 0x0300, 0x1E15},
{0x0113, 0x0301, 0x1E17}, {0x0113, 0x0340, 0x1E15}, {0x0113, 0x0341, 0x1E17}, {0x0114, 0x0327, 0x1E1C},
{0x0115, 0x0327, 0x1E1D}, {0x014C, 0x0300, 0x1E50}, {0x014C, 0x0301, 0x1E52}, {0x014C, 0x0328, 0x01EC},
{0x014C, 0x0340, 0x1E50}, {0x014C, 0x0341, 0x1E52}, {0x014D, 0x0300, 0x1E51}, {0x014D, 0x0301, 0x1E53},
{0x014D, 0x0328, 0x01ED}, {0x014D, 0x0340, 0x1E51}, {0x014D, 0x0341, 0x1E53}, {0x015A, 0x0307, 0x1E64},
{0x015B, 0x0307, 0x1E65}, {0x0160, 0x0307, 0x1E66}, {0x0161, 0x0307, 0x1E67}, {0x0168, 0x0301, 0x1E78},
{0x0168, 0x031B, 0x1EEE}, {0x0168, 0x0341, 0x1E78}, {0x0169, 0x0301, 0x1E79}, {0x0169, 0x031B, 0x1EEF},
{0x0169, 0x0341, 0x1E79}, {0x016A, 0x0308, 0x1E7A}, {0x016B, 0x0308, 0x1E7B}, {0x017F, 0x0307, 0x1E9B},
{0x01A0, 0x0300, 0x1EDC}, {0x01A0, 0x0301, 0x1EDA}, {0x01A0, 0x0303, 0x1EE0}, {0x01A0, 0x0309, 0x1EDE},
{0x01A0, 0x0323, 0x1EE2}, {0x01A0, 0x0340, 0x1EDC}, {0x01A0, 0x0341, 0x1EDA}, {0x01A1, 0x0300, 0x1EDD},
{0x01A1, 0x0301, 0x1EDB}, {0x01A1, 0x0303, 0x1EE1}, {0x01A1, 0x0309, 0x1EDF}, {0x01A1, 0x0323, 0x1EE3},
{0x01A1, 0x0340, 0x1EDD}, {0x01A1, 0x0341, 0x1EDB}, {0x01AF, 0x0300, 0x1EEA}, {0x01AF, 0x0301, 0x1EE8},
{0x01AF, 0x0303, 0x1EEE}, {0x01AF, 0x0309, 0x1EEC}, {0x01AF, 0x0323, 0x1EF0}, {0x01AF, 0x0340, 0x1EEA},
{0x01AF, 0x0341, 0x1EE8}, {0x01B0, 0x0300, 0x1EEB}, {0x01B0, 0x0301, 0x1EE9}, {0x01B0, 0x0303, 0x1EEF},
{0x01B0, 0x0309, 0x1EED}, {0x01B0, 0x0323, 0x1EF1}, {0x01B0, 0x0340, 0x1EEB}, {0x01B0, 0x0341, 0x1EE9},
{0x01B7, 0x030C, 0x01EE}, {0x01EA, 0x0304, 0x01EC}, {0x01EB, 0x0304, 0x01ED}, {0x0226, 0x0304, 0x01E0},
{0x0227, 0x0304, 0x01E1}, {0x0228, 0x0306, 0x1E1C}, {0x0229, 0x0306, 0x1E1D}, {0x022E, 0x0304, 0x0230},
{0x022F, 0x0304, 0x0231}, {0x0292, 0x030C, 0x01EF}, {0x0391, 0x0300, 0x1FBA}, {0x0391, 0x0301, 0x0386},
{0x0391, 0x0304, 0x1FB9}, {0x0391, 0x0306, 0x1FB8}, {0x0391, 0x0313, 0x1F08}, {0x0391, 0x0314, 0x1F09},
{0x0391, 0x0340, 0x1FBA}, {0x0391, 0x0341, 0x0386}, {0x0391, 0x0343, 0x1F08}, {0x0391, 0x0345, 0x1FBC},
{0x0395, 0x0300, 0x1FC8}, {0x0395, 0x0301, 0x0388}, {0x0395, 0x0313, 0x1F18}, {0x0395, 0x0314, 0x1F19},
{0x0395, 0x0340, 0x1FC8}, {0x0395, 0x0341, 0x0388}, {0x0395, 0x0343, 0x1F18}, {0x0397, 0x0300, 0x1FCA},
{0x0397, 0x0301, 0x0389}, {0x0397, 0x0313, 0x1F28}, {0x0397, 0x0314, 0x1F29}, {0x0397, 0x0340, 0x1FCA},
{0x0397, 0x0341, 0x0389}, {0x0397, 0x0343, 0x1F28}, {0x0397, 0x0345, 0x1FCC}, {0x0399, 0x0300, 0x1FDA},
{0x0399, 0x0301, 0x038A}, {0x0399, 0x0304, 0x1FD9}, {0x0399, 0x0306, 0x1FD8}, {0x0399, 0x0308, 0x03AA},
{0x0399, 0x0313, 0x1F38}, {0x0399, 0x0314, 0x1F39}, {0x0399, 0x0340, 0x1FDA}, {0x0399, 0x0341, 0x038A},
{0x0399, 0x0343, 0x1F38}, {0x039F, 0x0300, 0x1FF8}, {0x039F, 0x0301, 0x038C}, {0x039F, 0x0313, 0x1F48},
{0x039F, 0x0314, 0x1F49}, {0x039F, 0x0340, 0x1FF8}, {0x039F, 0x0341, 0x038C}, {0x039F, 0x0343, 0x1F48},
{0x03A1, 0x0314, 0x1FEC}, {0x03A5, 0x0300, 0x1FEA}, {0x03A5, 0x0301, 0x038E}, {0x03A5, 0x0304, 0x1FE9},
{0x03A5, 0x0306, 0x1FE8}, {0x03A5, 0x0308, 0x03AB}, {0x03A5, 0x0314, 0x1F59}, {0x03A5, 0x0340, 0x1FEA},
{0x03A5, 0x0341, 0x038E}, {0x03A9, 0x0300, 0x1FFA}, {0x03A9, 0x0301, 0x038F}, {0x03A9, 0x0313, 0x1F68},
{0x03A9, 0x0314, 0x1F69}, {0x03A9, 0x0340, 0x1FFA}, {0x03A9, 0x0341, 0x038F}, {0x03A9, 0x0343, 0x1F68},
{0x03A9, 0x0345, 0x1FFC}, {0x03AC, 0x0345, 0x1FB4}, {0x03AE, 0x0345, 0x1FC4}, {0x03B1, 0x0300, 0x1F70},
{0x03B1, 0x0301, 0x03AC}, {0x03B1, 0x0304, 0x1FB1}, {0x03B1, 0x0306, 0x1FB0}, {0x03B1, 0x0313, 0x1F00},
{0x03B1, 0x0314, 0x1F01}, {0x03B1, 0x0340, 0x1F70}, {0x03B1, 0x0341, 0x03AC}, {0x03B1, 0x0342, 0x1FB6},
{0x03B1, 0x0343, 0x1F00}, {0x03B1, 0x0345, 0x1FB3}, {0x03B5, 0x0300, 0x1F72}, {0x03B5, 0x0301, 0x03AD},
{0x03B5, 0x0313, 0x1F10}, {0x03B5, 0x0314, 0x1F11}, {0x03B5, 0x0340, 0x1F72}, {0x03B5, 0x0341, 0x03AD},
{0x03B5, 0x0343, 0x1F10}, {0x03B7, 0x0300, 0x1F74}, {0x03B7, 0x0301, 0x03AE}, {0x03B7, 0x0313, 0x1F20},
{0x03B7, 0x0314, 0x1F21}, {0x03B7, 0x0340, 0x1F74}, {0x03B7, 0x0341, 0x03AE}, {0x03B7, 0x0342, 0x1FC6},
{0x03B7, 0x0343, 0x1F20}, {0x03B7, 0x0345, 0x1FC3}, {0x03B9, 0x0300, 0x1F76}, {0x03B9, 0x0301, 0x03AF},
{0x03B9, 0x0304, 0x1FD1}, {0x03B9, 0x0306, 0x1FD0}, {0x03B9, 0x0308, 0x03CA}, {0x03B9, 0x0313, 0x1F30},
{0x03B9, 0x0314, 0x1F31}, {0x03B9, 0x0340, 0x1F76}, {0x03B9, 0x0341, 0x03AF}, {0x03B9, 0x0342, 0x1FD6},
{0x03B9, 0x0343, 0x1F30}, {0x03B9, 0x0344, 0x0390}, {0x03BF, 0x0300, 0x1F78}, {0x03BF, 0x0301, 0x03CC},
{0x03BF, 0x0313, 0x1F40}, {0x03BF, 0x0314, 0x1F41}, {0x03BF, 0x0340, 0x1F78}, {0x03BF, 0x0341, 0x03CC},
{0x03BF, 0x0343, 0x1F40}, {0x03C1, 0x0313, 0x1FE4}, {0x03C1, 0x0314, 0x1FE5}, {0x03C1, 0x0343, 0x1FE4},
{0x03C5, 0x0300, 0x1F7A}, {0x03C5, 0x0301, 0x03CD}, {0x03C5, 0x0304, 0x1FE1}, {0x03C5, 0x0306, 0x1FE0},
{0x03C5, 0x0308, 0x03CB}, {0x03C5, 0x0313, 0x1F50}, {0x03C5, 0x0314, 0x1F51}, {0x03C5, 0x0340, 0x1F7A},
{0x03C5, 0x0341, 0x03CD}, {0x03C5, 0x0342, 0x1FE6}, {0x03C5, 0x0343, 0x1F50}, {0x03C5, 0x0344, 0x03B0},
{0x03C9, 0x0300, 0x1F7C}, {0x03C9, 0x0301, 0x03CE}, {0x03C9, 0x0313, 0x1F60}, {0x03C9, 0x0314, 0x1F61},
{0x03C9, 0x0340, 0x1F7C}, {0x03C9, 0x0341, 0x03CE}, {0x03C9, 0x0342, 0x1FF6}, {0x03C9, 0x0343, 0x1F60},
{0x03C9, 0x0345, 0x1FF3}, {0x03CA, 0x0300, 0x1FD2}, {0x03CA, 0x0301, 0x0390}, {0x03CA, 0x0340, 0x1FD2},
{0x03CA, 0x0341, 0x0390}, {0x03CA, 0x0342, 0x1FD7}, {0x03CB, 0x0300, 0x1FE2}, {0x03CB, 0x0301, 0x03B0},
{0x03CB, 0x0340, 0x1FE2}, {0x03CB, 0x0341, 0x03B0}, {0x03CB, 0x0342, 0x1FE7}, {0x03CE, 0x0345, 0x1FF4},
{0x03D2, 0x0301, 0x03D3}, {0x03D2, 0x0308, 0x03D4}, {0x03D2, 0x0341, 0x03D3}, {0x0406, 0x0308, 0x0407},
{0x0410, 0x0306, 0x04D0}, {0x0410, 0x0308, 0x04D2}, {0x0413, 0x0301, 0x0403}, {0x0413, 0x0341, 0x0403},
{0x0415, 0x0300, 0x0400}, {0x0415, 0x0306, 0x04D6}, {0x0415, 0x0308, 0x0401}, {0x0415, 0x0340, 0x0400},
{0x0416, 0x0306, 0x04C1}, {0x0416, 0x0308, 0x04DC}, {0x0417, 0x0308, 0x04DE}, {0x0418, 0x0300, 0x040D},
{0x0418, 0x0304, 0x04E2}, {0x0418, 0x0306, 0x0419}, {0x0418, 0x0308, 0x04E4}, {0x0418, 0x0340, 0x040D},
{0x041A, 0x0301, 0x040C}, {0x041A, 0x0341, 0x040C}, {0x041E, 0x0308, 0x04E6}, {0x0423, 0x0304, 0x04EE},
{0x0423, 0x0306, 0x040E}, {0x0423, 0x0308, 0x04F0}, {0x0423, 0x030B, 0x04F2}, {0x0427, 0x0308, 0x04F4},
{0x042B, 0x0308, 0x04F8}, {0x042D, 0x0308, 0x04EC}, {0x0430, 0x0306, 0x04D1}, {0x0430, 0x0308, 0x04D3},
{0x0433, 0x0301, 0x0453}, {0x0433, 0x0341, 0x0453}, {0x0435, 0x0300, 0x0450}, {0x0435, 0x0306, 0x04D7},
{0x0435, 0x0308, 0x0451}, {0x0435, 0x0340, 0x0450}, {0x0436, 0x0306, 0x04C2}, {0x0436, 0x0308, 0x04DD},
{0x0437, 0x0308, 0x04DF}, {0x0438, 0x0300, 0x045D}, {0x0438, 0x0304, 0x04E3}, {0x0438, 0x0306, 0x0439},
{0x0438, 0x0308, 0x04E5}, {0x0438, 0x0340, 0x045D}, {0x043A, 0x0301, 0x045C}, {0x043A, 0x0341, 0x045C},
{0x043E, 0x0308, 0x04E7}, {0x0443, 0x0304, 0x04EF}, {0x0443, 0x0306, 0x045E}, {0x0443, 0x0308, 0x04F1},
{0x0443, 0x030B, 0x04F3}, {0x0447, 0x0308, 0x04F5}, {0x044B, 0x0308, 0x04F9}, {0x044D, 0x0308, 0x04ED},
{0x0456, 0x0308, 0x0457}, {0x0474, 0x030F, 0x0476}, {0x0475, 0x030F, 0x0477}, {0x04D8, 0x0308, 0x04DA},
{0x04D9, 0x0308, 0x04DB}, {0x04E8, 0x0308, 0x04EA}, {0x04E9, 0x0308, 0x04EB}, {0x1E36, 0x0304, 0x1E38},
{0x1E37, 0x0304, 0x1E39}, {0x1E5A, 0x0304, 0x1E5C}, {0x1E5B, 0x0304, 0x1E5D}, {0x1E60, 0x0323, 0x1E68},
{0x1E61, 0x0323, 0x1E69}, {0x1E62, 0x0307, 0x1E68}, {0x1E63, 0x0307, 0x1E69}, {0x1EA0, 0x0302, 0x1EAC},
{0x1EA0, 0x0306, 0x1EB6}, {0x1EA1, 0x0302, 0x1EAD}, {0x1EA1, 0x0306, 0x1EB7}, {0x1EB8, 0x0302, 0x1EC6},
{0x1EB9, 0x0302, 0x1EC7}, {0x1ECC, 0x0302, 0x1ED8}, {0x1ECC, 0x031B, 0x1EE2}, {0x1ECD, 0x0302, 0x1ED9},
{0x1ECD, 0x031B, 0x1EE3}, {0x1ECE, 0x031B, 0x1EDE}, {0x1ECF, 0x031B, 0x1EDF}, {0x1EE4, 0x031B, 0x1EF0},
{0x1EE5, 0x031B, 0x1EF1}, {0x1EE6, 0x031B, 0x1EEC}, {0x1EE7, 0x031B, 0x1EED},
};
static constexpr int kUtf8ComposeTableSize = sizeof(kUtf8ComposeTable) / sizeof(kUtf8ComposeTable[0]);
+35 -46
View File
@@ -173,53 +173,37 @@ bool Xtc::generateCoverBmp() const {
return false;
}
// Write 1-bit BMP header (top-down row order)
BmpHeader bmpHeader;
createBmpHeader(&bmpHeader, pageInfo.width, pageInfo.height, BmpRowOrder::TopDown);
coverBmp.write(reinterpret_cast<const uint8_t*>(&bmpHeader), sizeof(bmpHeader));
const uint32_t rowSize = ((pageInfo.width + 31) / 32) * 4;
// Write bitmap data
// BMP requires 4-byte row alignment
const size_t dstRowSize = (pageInfo.width + 7) / 8; // 1-bit destination row size
if (bitDepth == 2) {
// XTH 2-bit mode: preserve all 4 gray levels in a 2-bit BMP so the sleep
// screen's grayscale pass can render them (a 1-bit cover would silently
// disable it). Source is two bit planes, column-major order:
// XTH 2-bit mode: Two bit planes, column-major order
// - Columns scanned right to left (x = width-1 down to 0)
// - 8 vertical pixels per byte (MSB = topmost pixel in group)
// - First plane: Bit1, Second plane: Bit2
// - Pixel value = (bit1 << 1) | bit2: 0=white, 1=dark gray,
// 2=light gray, 3=black
// 70-byte 2-bit BMP header (14 file + 40 DIB + 4-entry gray palette),
// top-down rows. Only the size and dimension fields vary; patch them in.
// clang-format off
uint8_t hdr[70] = {
'B', 'M', 0, 0, 0, 0, 0, 0, 0, 0, 70, 0, 0, 0, // file header
40, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 2, 0, // DIB: w/h patched
0, 0, 0, 0, 0, 0, 0, 0, 0x13, 0x0B, 0, 0, 0x13, 0x0B, 0, 0, // 0, 2835 DPI
4, 0, 0, 0, 4, 0, 0, 0, // 4 palette colors
0x00, 0x00, 0x00, 0x00, 0x55, 0x55, 0x55, 0x00, // black, dark gray
0xAA, 0xAA, 0xAA, 0x00, 0xFF, 0xFF, 0xFF, 0x00}; // light gray, white
// clang-format on
const uint32_t rowSize2 = ((static_cast<uint32_t>(pageInfo.width) * 2 + 31) / 32) * 4;
const uint32_t imageSize = rowSize2 * pageInfo.height;
const uint32_t fileSize = sizeof(hdr) + imageSize;
const int32_t topDownHeight = -static_cast<int32_t>(pageInfo.height);
memcpy(hdr + 2, &fileSize, 4);
memcpy(hdr + 18, &pageInfo.width, 2); // biWidth (upper bytes stay 0)
memcpy(hdr + 22, &topDownHeight, 4); // negative biHeight = top-down
memcpy(hdr + 34, &imageSize, 4);
coverBmp.write(hdr, sizeof(hdr));
// - Pixel value = (bit1 << 1) | bit2
const size_t planeSize = (static_cast<size_t>(pageInfo.width) * pageInfo.height + 7) / 8;
const uint8_t* plane1 = pageBuffer; // Bit1 plane
const uint8_t* plane2 = pageBuffer + planeSize; // Bit2 plane
const size_t colBytes = (pageInfo.height + 7) / 8; // Bytes per column
// 2 bits per pixel, MSB first, rows padded to 4 bytes
uint8_t* rowBuffer = static_cast<uint8_t*>(malloc(rowSize2));
// Allocate a row buffer for 1-bit output
uint8_t* rowBuffer = static_cast<uint8_t*>(malloc(dstRowSize));
if (!rowBuffer) {
free(pageBuffer);
return false;
}
// XTH value -> BMP palette index (palette: 0=black, 1=dark, 2=light, 3=white)
static constexpr uint8_t kXthToBmp[4] = {3, 1, 2, 0};
for (uint16_t y = 0; y < pageInfo.height; y++) {
memset(rowBuffer, 0x00, rowSize2);
memset(rowBuffer, 0xFF, dstRowSize); // Start with all white
for (uint16_t x = 0; x < pageInfo.width; x++) {
// Column-major, right to left: column index = (width - 1 - x)
@@ -232,22 +216,28 @@ bool Xtc::generateCoverBmp() const {
const uint8_t bit2 = (plane2[byteOffset] >> bitInByte) & 1;
const uint8_t pixelValue = (bit1 << 1) | bit2;
const uint8_t bmpVal = kXthToBmp[pixelValue];
rowBuffer[(x * 2) / 8] |= bmpVal << (6 - ((x * 2) % 8));
// Threshold: 0=white (1); 1,2,3=black (0)
if (pixelValue >= 1) {
// Set bit to 0 (black) in BMP format
const size_t dstByte = x / 8;
const size_t dstBit = 7 - (x % 8);
rowBuffer[dstByte] &= ~(1 << dstBit);
}
}
// Row buffer is rowSize2 bytes and zero-padded, write it whole
coverBmp.write(rowBuffer, rowSize2);
// Write converted row
coverBmp.write(rowBuffer, dstRowSize);
// Pad to 4-byte boundary
uint8_t padding[4] = {0, 0, 0, 0};
size_t paddingSize = rowSize - dstRowSize;
if (paddingSize > 0) {
coverBmp.write(padding, paddingSize);
}
}
free(rowBuffer);
} else {
// Write 1-bit BMP header (top-down row order)
BmpHeader bmpHeader;
createBmpHeader(&bmpHeader, pageInfo.width, pageInfo.height, BmpRowOrder::TopDown);
coverBmp.write(reinterpret_cast<const uint8_t*>(&bmpHeader), sizeof(bmpHeader));
const uint32_t rowSize = ((pageInfo.width + 31) / 32) * 4;
// 1-bit source: write directly with proper padding
const size_t srcRowSize = (pageInfo.width + 7) / 8;
@@ -432,10 +422,9 @@ bool Xtc::generateThumbBmp(int height) const {
const uint8_t bit1 = (plane1[byteOffset] >> bitInByte) & 1;
const uint8_t bit2 = (plane2[byteOffset] >> bitInByte) & 1;
const uint8_t pixelValue = (bit1 << 1) | bit2;
// pixelValue: 0=white, 1=dark gray, 2=light gray, 3=black —
// same semantics as the cover's kXthToBmp mapping above
static constexpr uint8_t kXthToGray[4] = {255, 85, 170, 0};
grayValue = kXthToGray[pixelValue];
// Convert 2-bit (0-3) to grayscale: 0=black, 3=white
// pixelValue: 0=white, 1=light gray, 2=dark gray, 3=black (XTC polarity)
grayValue = (3 - pixelValue) * 85; // 0->255, 1->170, 2->85, 3->0
}
}
} else {
+15 -18
View File
@@ -397,37 +397,34 @@ uint8_t* ZipFile::readFileToMemory(const char* filename, size_t* size, const boo
// Continue out of block with data set
} else if (fileStat.method == ZIP_METHOD_DEFLATED) {
// Read out deflated content from file
const auto deflatedData = static_cast<uint8_t*>(malloc(deflatedDataSize));
if (deflatedData == nullptr) {
LOG_ERR("ZIP", "Failed to allocate memory for decompression buffer");
auto* fileReadBuffer = static_cast<uint8_t*>(malloc(1024));
if (!fileReadBuffer) {
LOG_ERR("ZIP", "Failed to allocate memory for zip file read buffer");
free(data);
return nullptr;
}
const size_t dataRead = file.read(deflatedData, deflatedDataSize);
ZipInflateCtx ctx;
ctx.file = &file;
ctx.fileRemaining = deflatedDataSize;
ctx.readBuf = fileReadBuffer;
ctx.readBufSize = 1024;
if (dataRead != deflatedDataSize) {
LOG_ERR("ZIP", "Failed to read data, expected %d got %d", deflatedDataSize, dataRead);
free(deflatedData);
if (!ctx.reader.init(true)) {
LOG_ERR("ZIP", "Failed to init inflate reader");
free(fileReadBuffer);
free(data);
return nullptr;
}
ctx.reader.setReadCallback(zipReadCallback);
bool success = false;
{
InflateReader r;
r.init(false);
r.setSource(deflatedData, deflatedDataSize);
success = r.read(data, inflatedDataSize);
}
free(deflatedData);
if (!success) {
if (!ctx.reader.read(data, inflatedDataSize)) {
LOG_ERR("ZIP", "Failed to inflate file");
free(fileReadBuffer);
free(data);
return nullptr;
}
free(fileReadBuffer);
// Continue out of block with data set
} else {
+21
View File
@@ -81,6 +81,27 @@ void HalDisplay::copyGrayscaleBuffers(const uint8_t* lsbBuffer, const uint8_t* m
einkDisplay.copyGrayscaleBuffers(lsbBuffer, msbBuffer);
}
void HalDisplay::displayGrayscaleBase(RefreshMode fallback, bool turnOffScreen) {
// X3: a HALF fallback means the caller wants a clean base (e.g. the sleep
// cover, a full-screen swap from arbitrary prior content). Without this, the
// X3 grayscale base takes its gentle differential happy path and the prior
// home/reader frame ghosts through the soft aa_pre_bw_mid waveform. Forcing a
// resync makes displayGrayscaleBase clear first, matching displayBuffer(HALF).
// The reader's FAST path is deliberately left on the differential path so
// per-page grayscale stays cheap.
if (gpio.deviceIsX3() && fallback == RefreshMode::HALF_REFRESH) {
einkDisplay.requestResync(1);
}
einkDisplay.displayGrayscaleBase(convertRefreshMode(fallback), turnOffScreen);
}
void HalDisplay::preconditionGrayscale() { einkDisplay.preconditionGrayscale(); }
void HalDisplay::preconditionGrayscale(uint16_t x, uint16_t y, uint16_t w, uint16_t h) {
einkDisplay.preconditionGrayscale(x, y, w, h);
}
void HalDisplay::copyGrayscaleLsbBuffers(const uint8_t* lsbBuffer) { einkDisplay.copyGrayscaleLsbBuffers(lsbBuffer); }
void HalDisplay::copyGrayscaleMsbBuffers(const uint8_t* msbBuffer) { einkDisplay.copyGrayscaleMsbBuffers(msbBuffer); }
+13
View File
@@ -47,6 +47,19 @@ class HalDisplay {
// Access to frame buffer
uint8_t* getFrameBuffer() const;
// X3 grayscale preconditioning (OEM "AA-pre-BW(mid)" settle pass), windowed
// to the gray region in physical panel coordinates (no-arg = full frame).
// Call after the BW base frame is displayed and before the grayscale planes
// are written; no-op on X4. See EInkDisplay::preconditionGrayscale.
void preconditionGrayscale();
void preconditionGrayscale(uint16_t x, uint16_t y, uint16_t w, uint16_t h);
// Display the framebuffer as the base frame for a grayscale overlay that
// follows. On X3, HALF fallback first requests a resync to match
// displayBuffer(HALF); FAST fallback keeps the OEM differential base waveform
// ("AA-pre-BW(mid)"). Other panels display normally with `fallback` mode.
void displayGrayscaleBase(RefreshMode fallback = HALF_REFRESH, bool turnOffScreen = false);
void copyGrayscaleBuffers(const uint8_t* lsbBuffer, const uint8_t* msbBuffer);
void copyGrayscaleLsbBuffers(const uint8_t* lsbBuffer);
void copyGrayscaleMsbBuffers(const uint8_t* msbBuffer);
+1 -1
View File
@@ -4,7 +4,7 @@ build_cache_dir = .cache
extra_configs = platformio.local.ini
[crosspoint]
version = 1.3.0
version = 1.4.1
[base]
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.37/platform-espressif32.zip
+17 -1
View File
@@ -65,6 +65,8 @@ class CrossPointSettings {
XTC_STATUS_BAR_MODE_COUNT
};
enum STATUS_BAR_CLOCK_MODE { STATUS_BAR_CLOCK_HIDE = 0, STATUS_BAR_CLOCK_RIGHT = 1, STATUS_BAR_CLOCK_LEFT = 2 };
enum ORIENTATION {
PORTRAIT = 0, // 480x800 logical coordinates (current default)
LANDSCAPE_CW = 1, // 800x480 logical coordinates, rotated 180° (swap top/bottom)
@@ -136,6 +138,17 @@ class CrossPointSettings {
// Short power button press actions
enum SHORT_PWRBTN { IGNORE = 0, SLEEP = 1, PAGE_TURN = 2, FORCE_REFRESH = 3, FOOTNOTES = 4, SHORT_PWRBTN_COUNT };
// Long-press Confirm action while reading an EPUB. The setting cycles through these values.
// Persisted in settings.json by index: any new function (e.g. dictionary, bookmark) MUST use a
// value >= 2 and be appended at the END of the enumValues array in SettingsList.h, otherwise the
// stored indices shift and existing saves are silently misinterpreted.
enum LONG_PRESS_MENU_FUNCTION {
LP_MENU_KOSYNC = 0,
LP_MENU_DISABLED = 1,
LP_MENU_BOOKMARK = 2,
LONG_PRESS_MENU_FUNCTION_COUNT
};
// Hide battery percentage
enum HIDE_BATTERY_PERCENTAGE { HIDE_NEVER = 0, HIDE_READER = 1, HIDE_ALWAYS = 2, HIDE_BATTERY_PERCENTAGE_COUNT };
@@ -177,7 +190,7 @@ class CrossPointSettings {
uint8_t statusBarBattery = 1;
uint8_t xtcStatusBarMode = XTC_STATUS_BAR_HIDE;
// Clock display in status bar (X3 only, requires DS3231 RTC)
uint8_t statusBarClock = 0;
uint8_t statusBarClock = STATUS_BAR_CLOCK_HIDE;
// Clock UTC offset in quarter-hour steps, biased by 48 so it fits in uint8_t.
// Value 48 = UTC+0, 0 = UTC-12:00, 104 = UTC+14:00.
// Quarter-hour granularity supports oddball zones like Nepal (+5:45) and Chatham (+12:45).
@@ -226,6 +239,9 @@ class CrossPointSettings {
uint8_t hideBatteryPercentage = HIDE_NEVER;
// Long-press page turn button behavior
uint8_t longPressButtonBehavior = OFF;
// Long-press Confirm function in EPUB reader (cycles through LONG_PRESS_MENU_FUNCTION values).
// Defaults to Disabled so shortcut-based bookmark toggling remains opt-in.
uint8_t longPressMenuFunction = LP_MENU_DISABLED;
// UI Theme
uint8_t uiTheme = LYRA;
// Sunlight fading compensation
+6
View File
@@ -423,6 +423,9 @@ bool JsonSettingsIO::saveBookmarks(const std::vector<BookmarkEntry>& bookmarks,
obj["xpath"] = bookmark.xpath;
obj["percentage"] = bookmark.percentage;
obj["summary"] = bookmark.summary;
obj["si"] = bookmark.computedSpineIndex;
obj["pc"] = bookmark.computedChapterPageCount;
obj["pp"] = bookmark.computedChapterProgress;
}
String json;
@@ -447,6 +450,9 @@ bool JsonSettingsIO::loadBookmarks(std::vector<BookmarkEntry>& bookmarks, const
bookmark.xpath = obj["xpath"] | std::string("");
bookmark.percentage = obj["percentage"] | static_cast<float>(0);
bookmark.summary = obj["summary"] | std::string("");
bookmark.computedSpineIndex = obj["si"] | static_cast<uint16_t>(0);
bookmark.computedChapterPageCount = obj["pc"] | static_cast<uint16_t>(0);
bookmark.computedChapterProgress = obj["pp"] | static_cast<uint16_t>(0);
}
LOG_DBG("BKM", "Loaded %zu bookmarks from file", bookmarks.size());
+21 -3
View File
@@ -1,7 +1,18 @@
#include "MappedInputManager.h"
#include <GfxRenderer.h>
#include "CrossPointSettings.h"
bool MappedInputManager::isNavDirectionSwapped() const {
// Key the swap on the orientation the screen is *actually* rendered at, not the persisted reader
// setting. The reader (and its modal menus) render rotated, so navigation/labels flip there; the
// home and settings UI render in portrait, so they never flip even when a rotated reader is configured.
const auto orientation = renderer.getOrientation();
return SETTINGS.frontButtonFollowOrientation &&
(orientation == GfxRenderer::PortraitInverted || orientation == GfxRenderer::LandscapeCounterClockwise);
}
bool MappedInputManager::mapButton(const Button button, bool (HalGPIO::*fn)(uint8_t) const) const {
const auto sideLayout = SETTINGS.sideButtonLayout;
@@ -49,6 +60,15 @@ bool MappedInputManager::mapButton(const Button button, bool (HalGPIO::*fn)(uint
default:
return false;
}
case Button::NavNext:
// Logical "next item" navigation: side Down + front Right, with the control axis flipped in
// INVERTED / LANDSCAPE_CCW (frontButtonFollowOrientation) so it matches the rotated hint labels.
return isNavDirectionSwapped() ? (mapButton(Button::Up, fn) || mapButton(Button::Left, fn))
: (mapButton(Button::Down, fn) || mapButton(Button::Right, fn));
case Button::NavPrevious:
// Logical "previous item" navigation: side Up + front Left, axis-flipped in the same orientations.
return isNavDirectionSwapped() ? (mapButton(Button::Down, fn) || mapButton(Button::Right, fn))
: (mapButton(Button::Up, fn) || mapButton(Button::Left, fn));
}
return false;
@@ -69,9 +89,7 @@ unsigned long MappedInputManager::getHeldTime() const { return gpio.getHeldTime(
MappedInputManager::Labels MappedInputManager::mapLabels(const char* back, const char* confirm, const char* previous,
const char* next) const {
// Swap previous/next labels to match the page turn direction swap in INVERTED and LANDSCAPE_CCW.
const bool swapLabels =
SETTINGS.frontButtonFollowOrientation && (SETTINGS.orientation == CrossPointSettings::INVERTED ||
SETTINGS.orientation == CrossPointSettings::LANDSCAPE_CCW);
const bool swapLabels = isNavDirectionSwapped();
const char* leftLabel = swapLabels ? next : previous;
const char* rightLabel = swapLabels ? previous : next;
+16 -2
View File
@@ -2,9 +2,11 @@
#include <HalGPIO.h>
class GfxRenderer;
class MappedInputManager {
public:
enum class Button { Back, Confirm, Left, Right, Up, Down, Power, PageBack, PageForward };
enum class Button { Back, Confirm, Left, Right, Up, Down, Power, PageBack, PageForward, NavNext, NavPrevious };
struct Labels {
const char* btn1;
@@ -13,7 +15,7 @@ class MappedInputManager {
const char* btn4;
};
explicit MappedInputManager(HalGPIO& gpio) : gpio(gpio) {}
MappedInputManager(HalGPIO& gpio, const GfxRenderer& renderer) : gpio(gpio), renderer(renderer) {}
void update() const { gpio.update(); }
bool wasPressed(Button button) const;
@@ -26,8 +28,20 @@ class MappedInputManager {
// Returns the raw front button index that was pressed this frame (or -1 if none).
int getPressedFrontButton() const;
// True when the control axis is flipped relative to the physical buttons: the user opted into
// orientation-following front buttons AND the screen is *currently rendered* rotated (INVERTED /
// LANDSCAPE_CCW). Keyed on the live renderer orientation rather than the persisted reader setting,
// so portrait UI (home, settings) never swaps while the reader and its menus do.
[[nodiscard]] bool isNavDirectionSwapped() const;
private:
HalGPIO& gpio;
// Logical-to-physical button mapping depends on what the user is actually looking at: when the
// screen is rendered rotated, the directional buttons must flip to match. The renderer is the only
// authority on the *live* orientation (the reader rotates it and restores portrait on exit), so we
// read it here instead of CrossPointSettings.orientation, which is just the persisted reader
// preference and stays "rotated" even while portrait UI like home/settings is on screen.
const GfxRenderer& renderer;
bool mapButton(Button button, bool (HalGPIO::*fn)(uint8_t) const) const;
};
+6 -4
View File
@@ -5,12 +5,16 @@
#include "CrossPointSettings.h"
namespace {
static uint8_t fontSizeEnumFromSettings() {
uint8_t e = SETTINGS.fontSize;
if (e >= CrossPointSettings::FONT_SIZE_COUNT) e = 1; // default to MEDIUM
return e;
}
} // namespace
void SdCardFontSystem::begin(GfxRenderer& renderer) {
registry_.discover();
@@ -74,10 +78,8 @@ void SdCardFontSystem::ensureLoaded(GfxRenderer& renderer) {
SETTINGS.sdFontFamilyName[0] = '\0';
return;
}
auto sizes = family->availableSizes();
uint8_t idx = sizeEnum;
if (idx >= sizes.size()) idx = sizes.size() - 1;
uint8_t wantedPt = sizes.empty() ? 0 : sizes[idx];
const auto* selected = family->findClosestReaderSize(sizeEnum);
const uint8_t wantedPt = selected ? selected->pointSize : 0;
if (!registryWasDirty && wantedPt == manager_.currentPointSize()) return;
LOG_DBG("SDFS", "Reloading %s: size %u -> %u (enum %u)%s", wantedFamily, manager_.currentPointSize(), wantedPt,
sizeEnum, registryWasDirty ? " [registry dirty]" : "");
+11 -5
View File
@@ -151,9 +151,10 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
StrId::STR_CAT_READER),
SettingInfo::Toggle(StrId::STR_HYPHENATION, &CrossPointSettings::hyphenationEnabled, "hyphenationEnabled",
StrId::STR_CAT_READER),
SettingInfo::Enum(StrId::STR_ORIENTATION, &CrossPointSettings::orientation,
{StrId::STR_PORTRAIT, StrId::STR_LANDSCAPE_CW, StrId::STR_INVERTED, StrId::STR_LANDSCAPE_CCW},
"orientation", StrId::STR_CAT_READER),
SettingInfo::Enum(
StrId::STR_ORIENTATION, &CrossPointSettings::orientation,
{StrId::STR_PORTRAIT, StrId::STR_LANDSCAPE_CW, StrId::STR_ORIENTATION_INVERTED, StrId::STR_LANDSCAPE_CCW},
"orientation", StrId::STR_CAT_READER),
SettingInfo::Toggle(StrId::STR_EXTRA_SPACING, &CrossPointSettings::extraParagraphSpacing,
"extraParagraphSpacing", StrId::STR_CAT_READER),
SettingInfo::Toggle(StrId::STR_TEXT_AA, &CrossPointSettings::textAntiAliasing, "textAntiAliasing",
@@ -171,12 +172,16 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
{StrId::STR_LONG_PRESS_BEHAVIOR_OFF, StrId::STR_LONG_PRESS_BEHAVIOR_SKIP,
StrId::STR_LONG_PRESS_BEHAVIOR_ORIENTATION},
"longPressButtonBehavior", StrId::STR_CAT_CONTROLS),
SettingInfo::Enum(StrId::STR_LONG_PRESS_MENU, &CrossPointSettings::longPressMenuFunction,
{StrId::STR_KOSYNC, StrId::STR_DISABLED, StrId::STR_BOOKMARK_OPTION}, "longPressMenuFunction",
StrId::STR_CAT_CONTROLS),
SettingInfo::Enum(
StrId::STR_SHORT_PWR_BTN, &CrossPointSettings::shortPwrBtn,
{StrId::STR_IGNORE, StrId::STR_SLEEP, StrId::STR_PAGE_TURN, StrId::STR_FORCE_REFRESH, StrId::STR_FOOTNOTES},
"shortPwrBtn", StrId::STR_CAT_CONTROLS),
SettingInfo::Toggle(StrId::STR_PWR_BTN_FOOTNOTE_BACK, &CrossPointSettings::pwrBtnFootnoteBack,
"pwrBtnFootnoteBack", StrId::STR_CAT_CONTROLS),
// --- System ---
SettingInfo::Value(
StrId::STR_TIME_TO_SLEEP, &CrossPointSettings::sleepTimeoutMinutes,
@@ -240,8 +245,9 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
StrId::STR_CUSTOMISE_STATUS_BAR),
// Clock entries (web settings only; device UI uses ClockOffsetActivity for the offset).
// Range 0..104 = quarter-hour steps from UTC-12:00 to UTC+14:00, biased by 48.
SettingInfo::Toggle(StrId::STR_CLOCK, &CrossPointSettings::statusBarClock, "statusBarClock",
StrId::STR_CUSTOMISE_STATUS_BAR),
SettingInfo::Enum(StrId::STR_CLOCK, &CrossPointSettings::statusBarClock,
{StrId::STR_HIDE, StrId::STR_DIR_LEFT, StrId::STR_DIR_RIGHT}, "statusBarClock",
StrId::STR_CUSTOMISE_STATUS_BAR),
SettingInfo::Value(StrId::STR_CLOCK_UTC_OFFSET, &CrossPointSettings::clockUtcOffsetQ, {0, 104, 1},
"clockUtcOffsetQ", StrId::STR_CUSTOMISE_STATUS_BAR),
SettingInfo::Enum(StrId::STR_CLOCK_FORMAT, &CrossPointSettings::clockFormat,
+1
View File
@@ -1,5 +1,6 @@
#include "ActivityManager.h"
#include <FontCacheManager.h>
#include <HalPowerManager.h>
#include <algorithm>
+9 -1
View File
@@ -218,7 +218,15 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const {
renderer.invertScreen();
}
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
if (hasGreyscale) {
// OEM grayscale pipeline base: on X3 this displays the frame with the
// dedicated "AA-pre-BW(mid)" differential waveform, leaving every pixel
// in the calibrated state the gray nudge refresh expects; on X4 it is a
// plain HALF refresh (previous behavior).
renderer.displayGrayscaleBase(HalDisplay::HALF_REFRESH);
} else {
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
}
if (hasGreyscale) {
bitmap.rewindToData();
+224 -102
View File
@@ -41,6 +41,8 @@ namespace {
// pagesPerRefresh now comes from SETTINGS.getRefreshFrequency()
// pages per minute, first item is 1 to prevent division by zero if accessed
constexpr int PAGE_TURN_RATES[] = {1, 1, 3, 6, 12};
constexpr size_t initialBookmarkCacheCapacity = 16;
constexpr float bookmarkProgressEpsilon = 0.0001f;
int clampPercent(int percent) {
if (percent < 0) {
@@ -63,6 +65,36 @@ bool isInReadFolder(const std::string& path) {
return path.size() > n && path.compare(0, n, READ_FOLDER) == 0 && path[n] == '/';
}
struct ProgressRange {
float start;
float end;
};
ProgressRange getPageProgressRange(const std::shared_ptr<Epub>& epub, const int spineIndex, const int page,
const int pageCount) {
if (pageCount <= 1) {
return {epub->calculateProgress(spineIndex, 0.0f), epub->calculateProgress(spineIndex, 1.0f)};
}
const float step = 1.0f / static_cast<float>(pageCount - 1);
const float anchor = std::clamp(static_cast<float>(page) * step, 0.0f, 1.0f);
const float start = std::max(0.0f, anchor - (step * 0.5f));
const float end = std::min(1.0f, anchor + (step * 0.5f));
return {epub->calculateProgress(spineIndex, start), epub->calculateProgress(spineIndex, end)};
}
bool bookmarkMatchesProgress(const BookmarkEntry& bookmark, const int spineIndex, const int page, const int pageCount,
const ProgressRange& pageRange) {
if (bookmark.computedSpineIndex == spineIndex && bookmark.computedChapterPageCount == pageCount &&
bookmark.computedChapterProgress == page) {
return true;
}
const float bookmarkProgress = std::clamp(bookmark.percentage, 0.0f, 1.0f);
return bookmarkProgress + bookmarkProgressEpsilon >= pageRange.start &&
bookmarkProgress - bookmarkProgressEpsilon <= pageRange.end;
}
// Pick a non-colliding destination path inside /Read/ for a finished book.
// Mirrors the suffixing scheme used elsewhere: "name.epub" -> "name (2).epub", etc.
std::string buildReadFolderDestination(const std::string& srcPath) {
@@ -165,6 +197,8 @@ void EpubReaderActivity::onEnter() {
APP_STATE.saveToFile();
RECENT_BOOKS.addBook(epub->getPath(), epub->getTitle(), epub->getAuthor(), epub->getThumbBmpPath());
loadCachedBookmarks();
// Trigger first update
requestUpdate();
}
@@ -177,6 +211,14 @@ void EpubReaderActivity::onExit() {
APP_STATE.readerActivityLoadCount = 0;
APP_STATE.saveToFile();
// Leaving mid-footnote loses the in-RAM return stack on deep sleep; persist the
// pre-footnote position so the book reopens at the link origin, not the footnote.
if (footnoteDepth > 0 && epub) {
const SavedPosition& origin = savedPositions[0];
saveProgress(origin.spineIndex, origin.pageNumber, 0);
}
section.reset();
if (pendingReadFolderMove && epub) {
const std::string srcPath = epub->getPath();
@@ -256,7 +298,9 @@ void EpubReaderActivity::loop() {
requestUpdate();
}
// Enter reader menu activity.
// Enter reader menu activity on short-press Confirm. A long-press that fired a bound
// function (bookmark or KOReader sync) sets ignoreNextConfirmRelease so the release
// following the hold does not also open the menu.
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (ignoreNextConfirmRelease) {
ignoreNextConfirmRelease = false;
@@ -271,7 +315,7 @@ void EpubReaderActivity::loop() {
const int bookProgressPercent = clampPercent(static_cast<int>(bookProgress + 0.5f));
startActivityForResult(std::make_unique<EpubReaderMenuActivity>(
renderer, mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent,
SETTINGS.orientation, !currentPageFootnotes.empty()),
SETTINGS.orientation, !currentPageFootnotes.empty(), !cachedBookmarks.empty()),
[this](const ActivityResult& result) {
// Always apply orientation change even if the menu was cancelled
const auto& menu = std::get<MenuResult>(result.data);
@@ -284,14 +328,32 @@ void EpubReaderActivity::loop() {
}
}
if (mappedInput.isPressed(MappedInputManager::Button::Confirm) &&
mappedInput.getHeldTime() >= ReaderUtils::BOOKMARK_HOLD_MS) {
if (!showBookmarkMessage) {
addBookmark();
showBookmarkMessage = true;
ignoreNextConfirmRelease = true; // Prevent accidental menu open after adding bookmark
bookmarkMessageTime = millis();
requestUpdate();
// Long-press Confirm runs the user-selected function (SETTINGS.longPressMenuFunction).
if (mappedInput.isPressed(MappedInputManager::Button::Confirm)) {
switch (SETTINGS.longPressMenuFunction) {
case CrossPointSettings::LP_MENU_BOOKMARK:
// Hold ~0.4s drops a bookmark at the current page.
if (mappedInput.getHeldTime() >= ReaderUtils::BOOKMARK_HOLD_MS && !showBookmarkMessage) {
addBookmark();
showBookmarkMessage = true;
ignoreNextConfirmRelease = true; // Prevent accidental menu open after adding bookmark
bookmarkMessageTime = millis();
requestUpdate();
}
break;
case CrossPointSettings::LP_MENU_KOSYNC:
// Hold ~1s launches KOReader sync. If sync can't run (no credentials stored), fall
// through so the normal Confirm-release still opens the reader menu.
if (mappedInput.getHeldTime() >= ReaderUtils::GO_HOME_MS) {
if (launchKOReaderSync()) {
ignoreNextConfirmRelease = true; // sync launched or error shown; suppress menu open
return;
}
}
break;
case CrossPointSettings::LP_MENU_DISABLED:
default:
break;
}
}
@@ -472,6 +534,7 @@ void EpubReaderActivity::jumpToPercent(int percent) {
void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action) {
auto progressChangeResultHandler = [this](const ActivityResult& result) {
loadCachedBookmarks();
if (!result.isCancelled) {
const auto& sync = std::get<ProgressChangeResult>(result.data);
if (currentSpineIndex != sync.spineIndex || (section && section->currentPage != sync.page)) {
@@ -578,50 +641,7 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
break;
}
case EpubReaderMenuActivity::MenuAction::SYNC: {
if (KOREADER_STORE.hasCredentials()) {
const int currentPage = section ? section->currentPage : nextPageNumber;
const int totalPages = section ? section->pageCount : cachedChapterTotalPageCount;
std::optional<uint16_t> paragraphIndex;
if (section && currentPage >= 0 && currentPage < section->pageCount) {
const uint16_t paragraphPage =
currentPage > 0 ? static_cast<uint16_t>(currentPage - 1) : static_cast<uint16_t>(currentPage);
if (const auto pIdx = section->getParagraphIndexForPage(paragraphPage)) {
paragraphIndex = *pIdx;
}
}
// Pre-compute local KO position and chapter name while Epub is still in RAM.
CrossPointPosition localPos = getCurrentPosition();
SavedProgressPosition localKoPos = ProgressMapper::toSavedProgress(epub, localPos);
const int tocIdx = epub->getTocIndexForSpineIndex(currentSpineIndex);
std::string localChapterName = (tocIdx >= 0) ? epub->getTocItem(tocIdx).title : "";
const std::string savedEpubPath = epub->getPath();
// Persist current position so the reader resumes at the right page on return.
// goToReader() depends on this file, so abort the sync if the write fails.
if (!saveProgress(currentSpineIndex, currentPage, totalPages)) {
LOG_ERR("KOSync", "Aborting sync because current progress could not be saved");
pendingSyncSaveError = true;
requestUpdate();
return;
}
// Release Epub and Section to free ~65KB RAM for the TLS handshake.
LOG_DBG("KOSync", "Releasing epub for sync (heap before: %u)", (unsigned)ESP.getFreeHeap());
{
RenderLock lock(*this);
if (section) {
nextPageNumber = section->currentPage;
}
section.reset();
epub.reset();
}
LOG_DBG("KOSync", "Epub released (heap after: %u)", (unsigned)ESP.getFreeHeap());
activityManager.replaceActivity(std::make_unique<KOReaderSyncActivity>(
renderer, mappedInput, savedEpubPath, currentSpineIndex, currentPage, totalPages, std::move(localKoPos),
std::move(localChapterName), paragraphIndex));
}
launchKOReaderSync();
break;
}
case EpubReaderMenuActivity::MenuAction::BOOKMARKS: {
@@ -630,9 +650,61 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
progressChangeResultHandler);
break;
}
case EpubReaderMenuActivity::MenuAction::TOGGLE_BOOKMARK: {
addBookmark();
break;
}
}
}
bool EpubReaderActivity::launchKOReaderSync() {
if (!KOREADER_STORE.hasCredentials()) return false; // no-op: nothing to launch
const int currentPage = section ? section->currentPage : nextPageNumber;
const int totalPages = section ? section->pageCount : cachedChapterTotalPageCount;
std::optional<uint16_t> paragraphIndex;
if (section && currentPage >= 0 && currentPage < section->pageCount) {
const uint16_t paragraphPage =
currentPage > 0 ? static_cast<uint16_t>(currentPage - 1) : static_cast<uint16_t>(currentPage);
if (const auto pIdx = section->getParagraphIndexForPage(paragraphPage)) {
paragraphIndex = *pIdx;
}
}
// Pre-compute local KO position and chapter name while Epub is still in RAM.
CrossPointPosition localPos = getCurrentPosition();
SavedProgressPosition localKoPos = ProgressMapper::toSavedProgress(epub, localPos);
const int tocIdx = epub->getTocIndexForSpineIndex(currentSpineIndex);
std::string localChapterName = (tocIdx >= 0) ? epub->getTocItem(tocIdx).title : "";
const std::string savedEpubPath = epub->getPath();
// Persist current position so the reader resumes at the right page on return.
// goToReader() depends on this file, so abort the sync if the write fails.
if (!saveProgress(currentSpineIndex, currentPage, totalPages)) {
LOG_ERR("KOSync", "Aborting sync because current progress could not be saved");
pendingSyncSaveError = true;
requestUpdate();
return true; // acted: surfaced a save error to the user
}
// Release Epub and Section to free ~65KB RAM for the TLS handshake.
LOG_DBG("KOSync", "Releasing epub for sync (heap before: %u)", (unsigned)ESP.getFreeHeap());
{
RenderLock lock(*this);
if (section) {
nextPageNumber = section->currentPage;
}
section.reset();
epub.reset();
}
LOG_DBG("KOSync", "Epub released (heap after: %u)", (unsigned)ESP.getFreeHeap());
activityManager.replaceActivity(std::make_unique<KOReaderSyncActivity>(
renderer, mappedInput, savedEpubPath, currentSpineIndex, currentPage, totalPages, std::move(localKoPos),
std::move(localChapterName), paragraphIndex));
return true; // acted: launched the sync activity
}
void EpubReaderActivity::applyOrientation(const uint8_t orientation) {
// No-op if the selected orientation matches current settings.
if (SETTINGS.orientation == orientation) {
@@ -869,6 +941,8 @@ void EpubReaderActivity::render(RenderLock&& lock) {
return;
}
updateBookmarkFlag();
{
auto p = section->loadPageFromSectionFile();
if (!p) {
@@ -900,7 +974,7 @@ void EpubReaderActivity::render(RenderLock&& lock) {
}
if (showBookmarkMessage) {
GUI.drawPopup(renderer, tr(STR_BOOKMARK_ADDED));
GUI.drawPopup(renderer, bookmarkRemoved ? tr(STR_BOOKMARK_REMOVED) : tr(STR_BOOKMARK_ADDED));
}
}
@@ -943,22 +1017,31 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
const int orientedMarginRight, const int orientedMarginBottom,
const int orientedMarginLeft) {
const auto t0 = millis();
const int fontId = SETTINGS.getReaderFontId();
// Font prewarm: scan pass accumulates text, then prewarm, then real render
auto* fcm = renderer.getFontCacheManager();
auto scope = fcm->createPrewarmScope();
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); // scan pass
page->render(renderer, fontId, orientedMarginLeft, orientedMarginTop); // scan pass
scope.endScanAndPrewarm();
const auto tPrewarm = millis();
// Force special handling for pages with images when anti-aliasing is on
bool imagePageWithAA = page->hasImages() && SETTINGS.textAntiAliasing;
const bool pageHasImages = page->hasImages();
const bool needsTextGrayscale = SETTINGS.textAntiAliasing;
const bool needsAnyGrayscale = needsTextGrayscale || pageHasImages;
auto renderGrayscalePass = [&]() {
if (needsTextGrayscale) {
page->render(renderer, fontId, orientedMarginLeft, orientedMarginTop);
} else {
page->renderImages(renderer, fontId, orientedMarginLeft, orientedMarginTop);
}
};
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
page->render(renderer, fontId, orientedMarginLeft, orientedMarginTop);
renderStatusBar();
const auto tBwRender = millis();
if (imagePageWithAA) {
if (pageHasImages) {
// Double FAST_REFRESH with selective image blanking (pablohc's technique):
// HALF_REFRESH sets particles too firmly for the grayscale LUT to adjust.
// Instead, blank only the image area and do two fast refreshes.
@@ -971,7 +1054,7 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
// Re-render page content to restore images into the blanked area
// Status bar is not re-rendered here to avoid reading stale dynamic values (e.g. battery %)
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
page->render(renderer, fontId, orientedMarginLeft, orientedMarginTop);
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
} else {
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
@@ -995,7 +1078,7 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
// per plane, but renderCharImpl culls out-of-band glyphs before decode so the
// cost stays close to one render. Both text (drawPixel) and images
// (DirectPixelWriter) honor the active strip target.
if (SETTINGS.textAntiAliasing && renderer.supportsStripGrayscale()) {
if (needsAnyGrayscale && renderer.supportsStripGrayscale()) {
constexpr int STRIP_ROWS = 80;
const int gh = renderer.getDisplayHeight();
const int gwBytes = renderer.getDisplayWidthBytes();
@@ -1011,7 +1094,7 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
const int rows = (gh - y < STRIP_ROWS) ? (gh - y) : STRIP_ROWS;
renderer.beginStripTarget(scratch.get(), y, rows);
renderer.clearScreen(0x00);
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
renderGrayscalePass();
renderer.endStripTarget();
renderer.writeGrayscalePlaneStrip(true, scratch.get(), y, rows);
}
@@ -1023,7 +1106,7 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
const int rows = (gh - y < STRIP_ROWS) ? (gh - y) : STRIP_ROWS;
renderer.beginStripTarget(scratch.get(), y, rows);
renderer.clearScreen(0x00);
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
renderGrayscalePass();
renderer.endStripTarget();
renderer.writeGrayscalePlaneStrip(false, scratch.get(), y, rows);
}
@@ -1048,22 +1131,28 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
} else {
// Fallback path for a controller without strip support. grayscale rendering
// TODO: Only do this if font supports it
if (SETTINGS.textAntiAliasing) {
if (needsAnyGrayscale) {
// Save the BW frame before the grayscale passes overwrite it, restore
// after. Only needed when grayscale actually renders.
renderer.storeBwBuffer();
if (!renderer.storeBwBuffer()) {
LOG_ERR("ERS", "Failed to store BW buffer for grayscale render; skipping grayscale this page");
const auto tEnd = millis();
LOG_DBG("ERS", "Page render: prewarm=%lums bw_render=%lums display=%lums total=%lums", tPrewarm - t0,
tBwRender - tPrewarm, tDisplay - tBwRender, tEnd - t0);
return;
}
const auto tBwStore = millis();
renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
renderGrayscalePass();
renderer.copyGrayscaleLsbBuffers();
const auto tGrayLsb = millis();
// Render and copy to MSB buffer
renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
renderGrayscalePass();
renderer.copyGrayscaleMsbBuffers();
const auto tGrayMsb = millis();
@@ -1081,8 +1170,8 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
tPrewarm - t0, tBwRender - tPrewarm, tDisplay - tBwRender, tBwStore - tDisplay, tGrayLsb - tBwStore,
tGrayMsb - tGrayLsb, tGrayDisplay - tGrayMsb, tBwRestore - tGrayDisplay, tEnd - t0);
} else {
// No anti-aliasing: BW frame already displayed above, no grayscale to
// render, so no save/restore.
// No text AA and no images: BW frame already displayed above, no grayscale
// to render, so no save/restore.
const auto tEnd = millis();
LOG_DBG("ERS", "Page render: prewarm=%lums bw_render=%lums display=%lums total=%lums", tPrewarm - t0,
tBwRender - tPrewarm, tDisplay - tBwRender, tEnd - t0);
@@ -1124,7 +1213,7 @@ void EpubReaderActivity::renderStatusBar() const {
title = epub->getTitle();
}
GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title, 0, textYOffset);
GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title, 0, textYOffset, true, currentPageBookmarked);
}
void EpubReaderActivity::navigateToHref(const std::string& hrefStr, const bool savePosition) {
@@ -1186,11 +1275,31 @@ void EpubReaderActivity::restoreSavedPosition() {
requestUpdate();
}
void EpubReaderActivity::loadCachedBookmarks() {
cachedBookmarks.clear();
if (cachedBookmarks.capacity() < initialBookmarkCacheCapacity) {
cachedBookmarks.reserve(initialBookmarkCacheCapacity);
}
if (!epub) {
currentPageBookmarked = false;
return;
}
const std::string bmPath = BookmarkUtil::getBookmarkPath(epub->getPath());
if (Storage.exists(bmPath.c_str())) {
String json = Storage.readFile(bmPath.c_str());
if (!json.isEmpty()) {
JsonSettingsIO::loadBookmarks(cachedBookmarks, json.c_str());
}
}
updateBookmarkFlag();
}
void EpubReaderActivity::addBookmark() {
if (!section || !epub) {
return;
}
LOG_DBG("ERS", "Adding bookmark at spine %d, page %d", currentSpineIndex, section ? section->currentPage : -1);
LOG_DBG("ERS", "Toggle bookmark at spine %d, page %d", currentSpineIndex, section ? section->currentPage : -1);
int currentPage;
int pageCount;
{
@@ -1199,45 +1308,58 @@ void EpubReaderActivity::addBookmark() {
currentPage = section->currentPage;
}
std::string pageText;
if (currentPage >= 0 && currentPage < pageCount) {
pageText = section->getTextFromSectionFile();
SavedProgressPosition progress = ProgressMapper::toSavedProgress(epub, getCurrentPosition());
const ProgressRange pageRange = getPageProgressRange(epub, currentSpineIndex, currentPage, pageCount);
const size_t bookmarkCountBeforeToggle = cachedBookmarks.size();
cachedBookmarks.erase(std::remove_if(cachedBookmarks.begin(), cachedBookmarks.end(),
[&](const BookmarkEntry& b) {
return bookmarkMatchesProgress(b, currentSpineIndex, currentPage, pageCount,
pageRange);
}),
cachedBookmarks.end());
if (cachedBookmarks.size() != bookmarkCountBeforeToggle) {
bookmarkRemoved = true;
currentPageBookmarked = false;
} else {
std::string pageText;
if (currentPage >= 0 && currentPage < pageCount) {
pageText = section->getTextFromSectionFile();
}
BookmarkEntry entry;
entry.percentage = progress.percentage;
entry.xpath = progress.xpath;
entry.summary = BookmarkUtil::sanitizeBookmarkSummary(pageText);
entry.computedSpineIndex = currentSpineIndex;
entry.computedChapterPageCount = pageCount;
entry.computedChapterProgress = currentPage;
cachedBookmarks.insert(cachedBookmarks.begin(), entry);
bookmarkRemoved = false;
currentPageBookmarked = true;
}
SavedProgressPosition progress = ProgressMapper::toSavedProgress(epub, getCurrentPosition());
BookmarkEntry entry;
entry.percentage = progress.percentage;
entry.xpath = progress.xpath;
entry.summary = BookmarkUtil::sanitizeBookmarkSummary(pageText);
// Add bookmark
const std::string path = BookmarkUtil::getBookmarkPath(epub->getPath());
LOG_DBG("ERS", "Bookmark path: %s", path.c_str());
const std::string bookmarksDir = BookmarkUtil::getBookmarksDir();
Storage.mkdir(bookmarksDir.c_str());
std::vector<BookmarkEntry> bookmarks;
if (Storage.exists(path.c_str())) {
LOG_DBG("ERS", "Existing bookmark file found, loading bookmarks");
String json = Storage.readFile(path.c_str());
if (!json.isEmpty()) {
JsonSettingsIO::loadBookmarks(bookmarks, json.c_str());
}
} else {
LOG_DBG("ERS", "No existing bookmark file, starting with empty bookmark list");
const bool ok = JsonSettingsIO::saveBookmarks(cachedBookmarks, path.c_str());
if (!ok) {
LOG_ERR("ERS", "Failed to save bookmarks to: %s", path.c_str());
}
bookmarks.insert(bookmarks.begin(), entry);
LOG_DBG("ERS", "Saving bookmark to file: %s", path.c_str());
const bool ok = JsonSettingsIO::saveBookmarks(bookmarks, path.c_str());
if (ok) {
showBookmarkMessage = true;
} else {
LOG_ERR("ERS", "Failed to save bookmark to: %s", path.c_str());
}
requestUpdate();
}
void EpubReaderActivity::updateBookmarkFlag() {
if (!section || !epub || cachedBookmarks.empty()) {
currentPageBookmarked = false;
return;
}
const ProgressRange pageRange =
getPageProgressRange(epub, currentSpineIndex, section->currentPage, section->pageCount);
currentPageBookmarked = std::any_of(cachedBookmarks.begin(), cachedBookmarks.end(), [&](const BookmarkEntry& b) {
return bookmarkMatchesProgress(b, currentSpineIndex, section->currentPage, section->pageCount, pageRange);
});
}
ScreenshotInfo EpubReaderActivity::getScreenshotInfo() const {
ScreenshotInfo info;
info.readerType = ScreenshotInfo::ReaderType::Epub;
@@ -5,6 +5,7 @@
#include <optional>
#include "BookmarkEntry.h"
#include "EpubReaderMenuActivity.h"
#include "ProgressMapper.h"
#include "activities/Activity.h"
@@ -34,6 +35,9 @@ class EpubReaderActivity final : public Activity {
bool automaticPageTurnActive = false;
bool showBookmarkMessage = false;
bool ignoreNextConfirmRelease = false;
bool currentPageBookmarked = false;
bool bookmarkRemoved = false; // true when last toggle removed (controls popup text)
std::vector<BookmarkEntry> cachedBookmarks;
// Tracks whether this book is currently removed from Recent Books by the
// removeReadBooksFromRecents feature (set at End-of-Book, cleared if paged back in).
bool recentsEntryRemoved = false;
@@ -60,10 +64,15 @@ class EpubReaderActivity final : public Activity {
// Jump to a percentage of the book (0-100), mapping it to spine and page.
void jumpToPercent(int percent);
void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action);
// Returns true if sync acted (launched, or surfaced a save error); false if it was a no-op
// because no KOReader credentials are stored.
bool launchKOReaderSync();
void applyOrientation(uint8_t orientation);
void toggleAutoPageTurn(uint8_t selectedPageTurnOption);
void pageTurn(bool isForwardTurn);
void loadCachedBookmarks();
void addBookmark();
void updateBookmarkFlag();
// Footnote navigation
void navigateToHref(const std::string& href, bool savePosition = false);
@@ -39,14 +39,6 @@ void EpubReaderBookmarksActivity::onEnter() {
bookmarks.shrink_to_fit();
} else {
JsonSettingsIO::loadBookmarks(bookmarks, json.c_str());
// pre-compute bookmark page values for quicker rendering
for (auto& bookmark : bookmarks) {
CrossPointPosition pos = ProgressMapper::toCrossPoint(epub, {bookmark.xpath, bookmark.percentage}, renderer);
bookmark.computedSpineIndex = pos.spineIndex;
bookmark.computedChapterPageCount = pos.totalPages;
bookmark.computedChapterProgress = pos.pageNumber;
}
}
} else {
LOG_DBG("EPB", "No bookmark file found at %s, starting with empty bookmarks", path.c_str());
@@ -93,6 +85,14 @@ void EpubReaderBookmarksActivity::loop() {
selectorIndex--;
}
if (bookmarks.empty()) {
ActivityResult result;
result.isCancelled = true;
setResult(std::move(result));
finish();
return;
}
requestUpdate();
confirmingDelete = DELETE_MODE_OFF;
return;
@@ -186,9 +186,12 @@ void EpubReaderBookmarksActivity::render(RenderLock&&) {
auto bookmark = bookmarks.at(confirmingDelete >= DELETE_MODE_DISPLAY ? selectorIndex : index);
auto tocIndex = epub->getTocIndexForSpineIndex(bookmark.computedSpineIndex);
auto tocTitle = (tocIndex >= 0) ? (epub->getTocItem(tocIndex)).title : tr(STR_UNNAMED);
return std::to_string((int)(std::clamp(bookmark.percentage, 0.0f, 1.0f) * 100.0f + 0.5f)) + "% - " +
std::to_string(bookmark.computedChapterProgress + 1) + "/" +
std::to_string(bookmark.computedChapterPageCount) + " - " + tocTitle;
std::string subtitle = std::to_string((int)(std::clamp(bookmark.percentage, 0.0f, 1.0f) * 100.0f + 0.5f)) + "% - ";
if (bookmark.computedChapterPageCount > 0) {
subtitle += std::to_string(bookmark.computedChapterProgress + 1) + "/" +
std::to_string(bookmark.computedChapterPageCount) + " - ";
}
return subtitle + tocTitle;
};
const auto getBookmarkIcon = [isPortrait](int index) {
// only enabled icon in portrait mode due to limitation with rotating icons for other orientations
@@ -208,16 +211,13 @@ void EpubReaderBookmarksActivity::render(RenderLock&&) {
getBookmarkTitle, getBookmarkSubtitle, getBookmarkIcon);
GUI.drawHelpText(renderer, Rect{contentX, pageHeight - hintGutterBottom, contentWidth, LINE_HEIGHT},
tr(STR_HOLD_CONFIRM_TO_DELETE));
tr(STR_HOLD_OPEN_TO_DELETE));
}
} else {
GUI.drawHelpText(renderer, Rect{contentX, LINE_HEIGHT * 2, contentWidth, LINE_HEIGHT},
tr(STR_BOOKMARK_INSTRUCTIONS));
}
const auto backLabel = confirmingDelete >= DELETE_MODE_DISPLAY ? tr(STR_CANCEL) : tr(STR_BACK);
const auto confirmLabel =
bookmarks.size() > 0 ? (confirmingDelete >= DELETE_MODE_DISPLAY ? tr(STR_DELETE) : tr(STR_OPEN)) : "";
bookmarks.size() > 0 ? (confirmingDelete >= DELETE_MODE_DISPLAY ? tr(STR_DELETE) : tr(STR_SELECT)) : "";
const auto labels = mappedInput.mapLabels(backLabel, confirmLabel, tr(STR_DIR_UP), tr(STR_DIR_DOWN));
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
@@ -10,23 +10,27 @@
EpubReaderMenuActivity::EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::string& title, const int currentPage, const int totalPages,
const int bookProgressPercent, const uint8_t currentOrientation,
const bool hasFootnotes)
const bool hasFootnotes, const bool hasBookmarks)
: Activity("EpubReaderMenu", renderer, mappedInput),
menuItems(buildMenuItems(hasFootnotes)),
menuItems(buildMenuItems(hasFootnotes, hasBookmarks)),
title(title),
pendingOrientation(currentOrientation),
currentPage(currentPage),
totalPages(totalPages),
bookProgressPercent(bookProgressPercent) {}
std::vector<EpubReaderMenuActivity::MenuItem> EpubReaderMenuActivity::buildMenuItems(bool hasFootnotes) {
std::vector<EpubReaderMenuActivity::MenuItem> EpubReaderMenuActivity::buildMenuItems(bool hasFootnotes,
bool hasBookmarks) {
std::vector<MenuItem> items;
items.reserve(11);
items.reserve(12);
items.push_back({MenuAction::SELECT_CHAPTER, StrId::STR_SELECT_CHAPTER});
if (hasFootnotes) {
items.push_back({MenuAction::FOOTNOTES, StrId::STR_FOOTNOTES});
}
items.push_back({MenuAction::BOOKMARKS, StrId::STR_BOOKMARKS});
if (hasBookmarks) {
items.push_back({MenuAction::BOOKMARKS, StrId::STR_BOOKMARKS});
}
items.push_back({MenuAction::TOGGLE_BOOKMARK, StrId::STR_TOGGLE_BOOKMARK});
items.push_back({MenuAction::ROTATE_SCREEN, StrId::STR_ORIENTATION});
items.push_back({MenuAction::AUTO_PAGE_TURN, StrId::STR_AUTO_TURN_PAGES_PER_MIN});
items.push_back({MenuAction::GO_TO_PERCENT, StrId::STR_GO_TO_PERCENT});
@@ -18,6 +18,7 @@ class EpubReaderMenuActivity final : public Activity {
AUTO_PAGE_TURN,
ROTATE_SCREEN,
BOOKMARKS,
TOGGLE_BOOKMARK,
SCREENSHOT,
DISPLAY_QR,
GO_HOME,
@@ -27,7 +28,7 @@ class EpubReaderMenuActivity final : public Activity {
explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title,
const int currentPage, const int totalPages, const int bookProgressPercent,
const uint8_t currentOrientation, const bool hasFootnotes);
const uint8_t currentOrientation, const bool hasFootnotes, bool hasBookmarks);
void onEnter() override;
void onExit() override;
@@ -40,7 +41,7 @@ class EpubReaderMenuActivity final : public Activity {
StrId labelId;
};
static std::vector<MenuItem> buildMenuItems(bool hasFootnotes);
static std::vector<MenuItem> buildMenuItems(bool hasFootnotes, bool hasBookmarks);
// Fixed menu layout
const std::vector<MenuItem> menuItems;
+4 -10
View File
@@ -1,23 +1,19 @@
#pragma once
#include <Epub.h>
#include <HalStorage.h>
#include <Logging.h>
#include "ProgressFile.h"
namespace EpubReaderUtils {
// Persists reader progress for an EPUB to its cache directory. Returns true on success.
inline bool saveProgress(Epub& epub, int spineIndex, int pageNumber, int pageCount) {
inline bool saveProgress(const Epub& epub, int spineIndex, int pageNumber, int pageCount) {
if (spineIndex < 0 || spineIndex > 0xFFFF || pageNumber < 0 || pageNumber > 0xFFFF || pageCount < 0 ||
pageCount > 0xFFFF) {
LOG_ERR("ERS", "Progress values out of range: spine=%d page=%d count=%d", spineIndex, pageNumber, pageCount);
return false;
}
HalFile f;
if (!Storage.openFileForWrite("ERS", epub.getCachePath() + "/progress.bin", f)) {
LOG_ERR("ERS", "Could not open progress file for write!");
return false;
}
uint8_t data[6];
data[0] = spineIndex & 0xFF;
data[1] = (spineIndex >> 8) & 0xFF;
@@ -25,9 +21,7 @@ inline bool saveProgress(Epub& epub, int spineIndex, int pageNumber, int pageCou
data[3] = (pageNumber >> 8) & 0xFF;
data[4] = pageCount & 0xFF;
data[5] = (pageCount >> 8) & 0xFF;
const size_t written = f.write(data, sizeof(data));
if (written != sizeof(data)) {
LOG_ERR("ERS", "Short write saving progress: %u/%u bytes", (unsigned)written, (unsigned)sizeof(data));
if (!ProgressFile::writeAtomic(epub.getCachePath(), data, sizeof(data))) {
return false;
}
LOG_DBG("ERS", "Progress saved: spine=%d page=%d", spineIndex, pageNumber);
+64
View File
@@ -0,0 +1,64 @@
#pragma once
#include <HalStorage.h>
#include <Logging.h>
#include <cstddef>
#include <cstdint>
#include <string>
namespace ProgressFile {
// Writes `len` bytes of reader progress to `<cachePath>/progress.bin` without
// ever leaving the canonical file half-written.
//
// The bytes go to a temporary `progress.bin.tmp` first; only once that is fully
// written and closed is it renamed over progress.bin. An interrupted write
// (power loss or a crash mid-SPI) therefore damages only the throwaway temp file.
// Previously a truncate-in-place write that was cut short left progress.bin with
// a broken FAT cluster chain that the firmware could neither rewrite nor clear,
// stranding the book on an old page (issue #2275).
//
// This is crash-safe, not metadata-atomic: on FAT the replace is remove + rename,
// two separate directory operations, so a crash between them can leave neither
// file -- which simply reads as "no saved progress" on next launch, never a
// corrupt or unclearable file. The point is that progress.bin is never torn.
//
// Note: this prevents corruption on a healthy card going forward. It cannot
// repair an already-corrupted progress.bin -- removing the stale file may itself
// fail at the FAT level, in which case recovery still requires fsck on a host.
//
// Returns true only if the new progress.bin is fully in place.
inline bool writeAtomic(const std::string& cachePath, const uint8_t* data, size_t len) {
const std::string finalPath = cachePath + "/progress.bin";
const std::string tmpPath = cachePath + "/progress.bin.tmp";
{
HalFile f;
if (!Storage.openFileForWrite("PRG", tmpPath, f)) {
LOG_ERR("PRG", "Could not open temp progress file for write: %s", tmpPath.c_str());
return false;
}
const size_t written = f.write(data, len);
if (written != len) {
LOG_ERR("PRG", "Short write saving progress to %s: %u/%u bytes", tmpPath.c_str(), (unsigned)written,
(unsigned)len);
return false;
}
f.flush();
// f (the temp file) is closed at scope exit (DESTRUCTOR_CLOSES_FILE=1) before
// the rename below -- SdFat must not rename a path that still has an open FsFile.
}
// SdFat's rename does not overwrite an existing destination, so drop the old
// canonical file first. The brief window where neither file exists reads as
// "no saved progress" on next launch -- never a corrupt, unclearable file.
Storage.remove(finalPath.c_str());
if (!Storage.rename(tmpPath.c_str(), finalPath.c_str())) {
LOG_ERR("PRG", "Failed to rename temp progress into place: %s", finalPath.c_str());
return false;
}
return true;
}
} // namespace ProgressFile
+16 -3
View File
@@ -2,6 +2,7 @@
#include <FsHelpers.h>
#include <HalStorage.h>
#include <Memory.h>
#include "CrossPointSettings.h"
#include "Epub.h"
@@ -29,7 +30,11 @@ std::unique_ptr<Epub> ReaderActivity::loadEpub(const std::string& path) {
return nullptr;
}
auto epub = std::unique_ptr<Epub>(new Epub(path, "/.crosspoint"));
auto epub = makeUniqueNoThrow<Epub>(path, "/.crosspoint");
if (!epub) {
LOG_ERR("READER", "Failed to allocate EPUB object");
return nullptr;
}
if (epub->load(true, SETTINGS.embeddedStyle == 0)) {
return epub;
}
@@ -44,7 +49,11 @@ std::unique_ptr<Xtc> ReaderActivity::loadXtc(const std::string& path) {
return nullptr;
}
auto xtc = std::unique_ptr<Xtc>(new Xtc(path, "/.crosspoint"));
auto xtc = makeUniqueNoThrow<Xtc>(path, "/.crosspoint");
if (!xtc) {
LOG_ERR("READER", "Failed to allocate XTC object");
return nullptr;
}
if (xtc->load()) {
return xtc;
}
@@ -59,7 +68,11 @@ std::unique_ptr<Txt> ReaderActivity::loadTxt(const std::string& path) {
return nullptr;
}
auto txt = std::unique_ptr<Txt>(new Txt(path, "/.crosspoint"));
auto txt = makeUniqueNoThrow<Txt>(path, "/.crosspoint");
if (!txt) {
LOG_ERR("READER", "Failed to allocate TXT object");
return nullptr;
}
if (txt->load()) {
return txt;
}
+1 -3
View File
@@ -43,9 +43,7 @@ inline PageTurnResult detectPageTurn(const MappedInputManager& input) {
const bool usePress = SETTINGS.longPressButtonBehavior == SETTINGS.OFF;
const bool tiltNext = SETTINGS.tiltPageTurn && halTiltSensor.wasTiltedForward();
const bool tiltPrev = SETTINGS.tiltPageTurn && halTiltSensor.wasTiltedBack();
const bool swapFront =
SETTINGS.frontButtonFollowOrientation && (SETTINGS.orientation == CrossPointSettings::INVERTED ||
SETTINGS.orientation == CrossPointSettings::LANDSCAPE_CCW);
const bool swapFront = input.isNavDirectionSwapped();
const auto prevButton = swapFront ? MappedInputManager::Button::Right : MappedInputManager::Button::Left;
const auto nextButton = swapFront ? MappedInputManager::Button::Left : MappedInputManager::Button::Right;
const bool prev =
+8 -8
View File
@@ -11,6 +11,7 @@
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "MappedInputManager.h"
#include "ProgressFile.h"
#include "ReaderUtils.h"
#include "RecentBooksStore.h"
#include "components/UITheme.h"
@@ -421,14 +422,13 @@ void TxtReaderActivity::renderStatusBar() const {
}
void TxtReaderActivity::saveProgress() const {
HalFile f;
if (Storage.openFileForWrite("TRS", txt->getCachePath() + "/progress.bin", f)) {
uint8_t data[4];
data[0] = currentPage & 0xFF;
data[1] = (currentPage >> 8) & 0xFF;
data[2] = 0;
data[3] = 0;
f.write(data, 4);
uint8_t data[4];
data[0] = currentPage & 0xFF;
data[1] = (currentPage >> 8) & 0xFF;
data[2] = 0;
data[3] = 0;
if (!ProgressFile::writeAtomic(txt->getCachePath(), data, sizeof(data))) {
LOG_ERR("TRS", "Failed to save progress: page %d", currentPage);
}
}
+21 -10
View File
@@ -17,6 +17,7 @@
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "MappedInputManager.h"
#include "ProgressFile.h"
#include "ReaderUtils.h"
#include "RecentBooksStore.h"
#include "XtcReaderChapterSelectionActivity.h"
@@ -293,7 +294,19 @@ void XtcReaderActivity::renderPage() {
}
}
ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh);
if (pagesUntilFullRefresh <= 1) {
// Periodic ghost cleanup: scrub via the normal path, then run the
// settle flavor of the grayscale base pass (DTM planes are equal after
// the display sync, so only the gentle reinforcement cells fire).
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
renderer.preconditionGrayscale();
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
} else {
// OEM grayscale pipeline base: differential "AA-pre-BW(mid)" update as
// the page turn on X3; plain FAST refresh on X4 (previous behavior).
renderer.displayGrayscaleBase(HalDisplay::FAST_REFRESH);
pagesUntilFullRefresh--;
}
// Pass 2: LSB buffer - mark DARK gray only (XTH value 1)
// In LUT: 0 bit = apply gray effect, 1 bit = untouched
@@ -375,15 +388,13 @@ void XtcReaderActivity::renderPage() {
}
void XtcReaderActivity::saveProgress() const {
HalFile f;
if (Storage.openFileForWrite("XTR", xtc->getCachePath() + "/progress.bin", f)) {
uint8_t data[4];
data[0] = currentPage & 0xFF;
data[1] = (currentPage >> 8) & 0xFF;
data[2] = (currentPage >> 16) & 0xFF;
data[3] = (currentPage >> 24) & 0xFF;
f.write(data, 4);
f.close();
uint8_t data[4];
data[0] = currentPage & 0xFF;
data[1] = (currentPage >> 8) & 0xFF;
data[2] = (currentPage >> 16) & 0xFF;
data[3] = (currentPage >> 24) & 0xFF;
if (!ProgressFile::writeAtomic(xtc->getCachePath(), data, sizeof(data))) {
LOG_ERR("XTR", "Failed to save progress: page %lu", currentPage);
}
}
+39 -6
View File
@@ -10,6 +10,8 @@
#include "CrossPointSettings.h"
#include "MappedInputManager.h"
#include "SilentRestart.h"
#include "activities/network/WifiSelectionActivity.h"
#include "components/UITheme.h"
#include "fontIds.h"
@@ -17,14 +19,46 @@ void ClockSyncActivity::onEnter() {
Activity::onEnter();
state = SYNCING;
syncedTime[0] = '\0';
if (WiFi.status() == WL_CONNECTED) {
requestUpdate();
return;
}
shouldTearDownWifiOnExit = true;
launchWifiSelection();
}
void ClockSyncActivity::onExit() {
Activity::onExit();
if (shouldTearDownWifiOnExit && WiFi.getMode() != WIFI_MODE_NULL) {
WiFi.disconnect(false);
delay(30);
silentRestart();
}
}
void ClockSyncActivity::launchWifiSelection() {
LOG_INF("CLK", "Manual sync requested without WiFi, launching WiFi selection");
startActivityForResult(std::make_unique<WifiSelectionActivity>(renderer, mappedInput),
[this](const ActivityResult& result) { onWifiSelectionComplete(!result.isCancelled); });
}
void ClockSyncActivity::onWifiSelectionComplete(const bool connected) {
if (!connected) {
LOG_INF("CLK", "WiFi selection cancelled before manual clock sync");
finish();
return;
}
state = SYNCING;
requestUpdate();
}
void ClockSyncActivity::onExit() { Activity::onExit(); }
void ClockSyncActivity::runSync() {
if (WiFi.status() != WL_CONNECTED) {
LOG_INF("CLK", "Manual sync requested but WiFi is not connected");
LOG_INF("CLK", "Manual sync requested but WiFi is not connected after selection");
state = NO_WIFI;
requestUpdate();
return;
@@ -59,8 +93,7 @@ void ClockSyncActivity::loop() {
return;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Back) ||
mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
finish();
}
}
@@ -100,7 +133,7 @@ void ClockSyncActivity::render(RenderLock&&) {
}
if (state != SYNCING) {
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_OK_BUTTON), "", "");
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", "");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
}
+5 -1
View File
@@ -3,7 +3,8 @@
#include "activities/Activity.h"
// Manual NTP resync action. Runs a forced sync (bypassing the once-per-device debounce),
// reports success/failure, then waits for Back. Requires WiFi to already be connected.
// reports success/failure, then waits for Back. If WiFi is not connected yet, it reuses the
// normal WiFi selection flow first.
class ClockSyncActivity final : public Activity {
public:
explicit ClockSyncActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
@@ -19,6 +20,9 @@ class ClockSyncActivity final : public Activity {
enum State { SYNCING, SUCCESS, NO_WIFI, FAILED };
State state = SYNCING;
char syncedTime[16] = {0};
bool shouldTearDownWifiOnExit = false;
void runSync();
void launchWifiSelection();
void onWifiSelectionComplete(bool connected);
};
+126 -39
View File
@@ -1,13 +1,36 @@
#include "FontSelectionActivity.h"
#include <FontCacheManager.h>
#include <GfxRenderer.h>
#include <I18n.h>
#include <algorithm>
#include <cstdio>
#include <cstring>
#include "CrossPointSettings.h"
#include "MappedInputManager.h"
#include "SdCardFontSystem.h"
#include "components/UITheme.h"
#include "fontIds.h"
namespace {
constexpr const char* ELLIPSIS_UTF8 = "\xe2\x80\xa6";
int findCurrentFontIndex(const SdCardFontRegistry* registry, const char* sdFontFamilyName, uint8_t fontFamily) {
if (sdFontFamilyName[0] != '\0' && registry) {
const auto& families = registry->getFamilies();
for (int i = 0; i < static_cast<int>(families.size()); i++) {
if (families[i].name == sdFontFamilyName) {
return CrossPointSettings::BUILTIN_FONT_COUNT + i;
}
}
}
return fontFamily < CrossPointSettings::BUILTIN_FONT_COUNT ? fontFamily : 0;
}
} // namespace
FontSelectionActivity::FontSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const SdCardFontRegistry* registry)
: Activity("FontSelect", renderer, mappedInput), registry_(registry) {}
@@ -15,12 +38,22 @@ FontSelectionActivity::FontSelectionActivity(GfxRenderer& renderer, MappedInputM
void FontSelectionActivity::onEnter() {
Activity::onEnter();
// Build combined font list: built-in + SD card fonts
// Get metrics and calculate layout dimensions
metrics_ = UITheme::getInstance().getMetrics();
afterHeader = metrics_.topPadding + metrics_.headerHeight + metrics_.verticalSpacing;
bottomReserved = metrics_.buttonHintsHeight + metrics_.verticalSpacing;
usableHeight = renderer.getScreenHeight() - afterHeader - bottomReserved;
previewHeight = usableHeight * metrics_.previewHeightPercent / 100;
originalFontFamily_ = SETTINGS.fontFamily;
strncpy(originalSdFontFamilyName_, SETTINGS.sdFontFamilyName, sizeof(originalSdFontFamilyName_) - 1);
originalSdFontFamilyName_[sizeof(originalSdFontFamilyName_) - 1] = '\0';
fonts_.clear();
fonts_.reserve(CrossPointSettings::BUILTIN_FONT_COUNT + (registry_ ? registry_->getFamilyCount() : 0));
fonts_.push_back({I18N.get(StrId::STR_NOTO_SERIF), true, 0});
fonts_.push_back({I18N.get(StrId::STR_NOTO_SANS), true, 1});
fonts_.push_back({I18N.get(StrId::STR_NOTO_SERIF), true, static_cast<uint8_t>(CrossPointSettings::NOTOSERIF)});
fonts_.push_back({I18N.get(StrId::STR_NOTO_SANS), true, static_cast<uint8_t>(CrossPointSettings::NOTOSANS)});
if (registry_) {
const auto& families = registry_->getFamilies();
@@ -29,19 +62,8 @@ void FontSelectionActivity::onEnter() {
}
}
// Find current selection
selectedIndex_ = 0;
if (SETTINGS.sdFontFamilyName[0] != '\0' && registry_) {
const auto& families = registry_->getFamilies();
for (int i = 0; i < static_cast<int>(families.size()); i++) {
if (families[i].name == SETTINGS.sdFontFamilyName) {
selectedIndex_ = CrossPointSettings::BUILTIN_FONT_COUNT + i;
break;
}
}
} else {
selectedIndex_ = SETTINGS.fontFamily < CrossPointSettings::BUILTIN_FONT_COUNT ? SETTINGS.fontFamily : 0;
}
selectedIndex_ = findCurrentFontIndex(registry_, SETTINGS.sdFontFamilyName, SETTINGS.fontFamily);
previewFontIndex_ = selectedIndex_;
requestUpdate();
}
@@ -50,17 +72,40 @@ void FontSelectionActivity::onExit() { Activity::onExit(); }
void FontSelectionActivity::loop() {
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
SETTINGS.fontFamily = originalFontFamily_;
strncpy(SETTINGS.sdFontFamilyName, originalSdFontFamilyName_, sizeof(SETTINGS.sdFontFamilyName) - 1);
SETTINGS.sdFontFamilyName[sizeof(SETTINGS.sdFontFamilyName) - 1] = '\0';
sdFontSystem.ensureLoaded(renderer);
finish();
return;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
handleSelection();
if (selectedIndex_ == previewFontIndex_) {
handleSelection();
} else {
previewFontIndex_ = selectedIndex_;
const auto& font = fonts_[selectedIndex_];
if (font.isBuiltin) {
SETTINGS.fontFamily = font.settingIndex;
SETTINGS.sdFontFamilyName[0] = '\0';
} else if (registry_) {
const int sdIdx = font.settingIndex - CrossPointSettings::BUILTIN_FONT_COUNT;
const auto& families = registry_->getFamilies();
if (sdIdx < static_cast<int>(families.size())) {
strncpy(SETTINGS.sdFontFamilyName, families[sdIdx].name.c_str(), sizeof(SETTINGS.sdFontFamilyName) - 1);
SETTINGS.sdFontFamilyName[sizeof(SETTINGS.sdFontFamilyName) - 1] = '\0';
sdFontSystem.ensureLoaded(renderer);
}
}
requestUpdate();
}
return;
}
const int listSize = static_cast<int>(fonts_.size());
const int pageItems = UITheme::getNumberOfItemsPerPage(renderer, true, false, true, false);
const int pageItems =
UITheme::getNumberOfItemsPerPage(renderer, true, false, true, false, previewHeight + metrics_.verticalSpacing);
buttonNavigator_.onNextRelease([this, listSize] {
selectedIndex_ = ButtonNavigator::nextIndex(selectedIndex_, listSize);
@@ -89,7 +134,7 @@ void FontSelectionActivity::handleSelection() {
SETTINGS.fontFamily = font.settingIndex;
SETTINGS.sdFontFamilyName[0] = '\0';
} else if (registry_) {
int sdIdx = font.settingIndex - CrossPointSettings::BUILTIN_FONT_COUNT;
const int sdIdx = font.settingIndex - CrossPointSettings::BUILTIN_FONT_COUNT;
const auto& families = registry_->getFamilies();
if (sdIdx < static_cast<int>(families.size())) {
strncpy(SETTINGS.sdFontFamilyName, families[sdIdx].name.c_str(), sizeof(SETTINGS.sdFontFamilyName) - 1);
@@ -99,39 +144,81 @@ void FontSelectionActivity::handleSelection() {
finish();
}
void FontSelectionActivity::renderPreviewPane(int top, int height, int fontId, const char* fontName) const {
const int left = metrics_.previewPadding;
const int width = renderer.getScreenWidth() - (metrics_.previewPadding * 2);
if (width <= 0 || height <= 0) return;
const int labelFontId = UI_10_FONT_ID;
const int labelH = renderer.getTextHeight(labelFontId);
const int labelGap = 4;
const int labelReserved = labelH + labelGap + metrics_.previewPadding;
char labelBuf[128];
snprintf(labelBuf, sizeof(labelBuf), "%s \"%s\"", tr(STR_PREVIEW), fontName ? fontName : "");
const int labelY = top + height - metrics_.previewPadding - labelH;
renderer.drawText(labelFontId, left, labelY, labelBuf);
if (fontId == 0) return;
const int lineH = renderer.getTextHeight(fontId);
if (lineH <= 0) return;
const int innerHeight = height - metrics_.previewPadding - labelReserved;
const int maxLines = std::max(1, innerHeight / (lineH + 2));
const char* previewText = I18N.get(StrId::STR_FONT_PREVIEW_TEXT);
if (auto* fcm = renderer.getFontCacheManager()) {
char prewarmBuf[256];
snprintf(prewarmBuf, sizeof(prewarmBuf), "%s %s", previewText, ELLIPSIS_UTF8);
fcm->prewarmCache(fontId, prewarmBuf, 0x01);
}
const auto lines = renderer.wrappedText(fontId, previewText, width, maxLines);
int y = top + metrics_.previewPadding;
const int textBottomLimit = top + height - labelReserved;
for (const auto& line : lines) {
if (y + lineH > textBottomLimit) break;
renderer.drawText(fontId, left, y, line.c_str());
y += lineH + 2;
}
}
void FontSelectionActivity::render(RenderLock&&) {
renderer.clearScreen();
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
const auto& metrics = UITheme::getInstance().getMetrics();
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_FONT_FAMILY));
GUI.drawHeader(renderer, Rect{0, metrics_.topPadding, pageWidth, metrics_.headerHeight}, tr(STR_FONT_FAMILY));
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing;
const int previewTop = afterHeader;
const int listTop = previewTop + previewHeight + metrics_.verticalSpacing;
const int listHeight = usableHeight - previewHeight - metrics_.verticalSpacing;
// Determine which font index is currently active (to mark as "Selected")
int currentFontIndex = 0;
if (SETTINGS.sdFontFamilyName[0] != '\0' && registry_) {
const auto& families = registry_->getFamilies();
for (int i = 0; i < static_cast<int>(families.size()); i++) {
if (families[i].name == SETTINGS.sdFontFamilyName) {
currentFontIndex = CrossPointSettings::BUILTIN_FONT_COUNT + i;
break;
}
}
} else {
currentFontIndex = SETTINGS.fontFamily < CrossPointSettings::BUILTIN_FONT_COUNT ? SETTINGS.fontFamily : 0;
}
const int previewFontId = SETTINGS.getReaderFontId();
const char* previewFontName = (previewFontIndex_ >= 0 && previewFontIndex_ < static_cast<int>(fonts_.size()))
? fonts_[previewFontIndex_].name.c_str()
: nullptr;
renderPreviewPane(previewTop, previewHeight, previewFontId, previewFontName);
renderer.drawLine(0, listTop - metrics_.verticalSpacing / 2, pageWidth, listTop - metrics_.verticalSpacing / 2);
const int currentFontIndex = findCurrentFontIndex(registry_, originalSdFontFamilyName_, originalFontFamily_);
GUI.drawList(
renderer, Rect{0, contentTop, pageWidth, contentHeight}, static_cast<int>(fonts_.size()), selectedIndex_,
renderer, Rect{0, listTop, pageWidth, listHeight}, static_cast<int>(fonts_.size()), selectedIndex_,
[this](int index) { return fonts_[index].name; }, nullptr, nullptr,
[this, currentFontIndex](int index) -> std::string { return index == currentFontIndex ? tr(STR_SELECTED) : ""; },
[this, currentFontIndex](int index) -> std::string {
if (index == previewFontIndex_ && index != currentFontIndex) return tr(STR_PREVIEW);
if (index == currentFontIndex) return tr(STR_SELECTED);
return "";
},
true);
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
const bool onPreviewed = selectedIndex_ == previewFontIndex_;
const char* confirmLabel = onPreviewed ? tr(STR_SELECT) : tr(STR_PREVIEW);
const auto labels = mappedInput.mapLabels(tr(STR_BACK), confirmLabel, tr(STR_DIR_UP), tr(STR_DIR_DOWN));
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
@@ -2,10 +2,12 @@
#include <SdCardFontRegistry.h>
#include <cstdint>
#include <string>
#include <vector>
#include "activities/Activity.h"
#include "components/themes/BaseTheme.h"
#include "util/ButtonNavigator.h"
class FontSelectionActivity final : public Activity {
@@ -20,15 +22,26 @@ class FontSelectionActivity final : public Activity {
private:
void handleSelection();
int getFontIdForPreview(int index) const;
void renderPreviewPane(int top, int height, int fontId, const char* fontName) const;
struct FontEntry {
std::string name;
bool isBuiltin;
uint8_t settingIndex; // index used by valueSetter
uint8_t settingIndex;
};
const SdCardFontRegistry* registry_;
ButtonNavigator buttonNavigator_;
std::vector<FontEntry> fonts_;
int selectedIndex_ = 0;
int previewFontIndex_ = 0;
uint8_t originalFontFamily_ = 0;
char originalSdFontFamilyName_[32] = {};
ThemeMetrics metrics_ = {};
int afterHeader = 0;
int bottomReserved = 0;
int usableHeight = 0;
int previewHeight = 0;
};
@@ -77,6 +77,9 @@ const StrId titleNames[TITLE_ITEMS] = {StrId::STR_BOOK, StrId::STR_CHAPTER, StrI
constexpr int XTC_STATUS_BAR_ITEMS = 3;
const StrId xtcStatusBarNames[XTC_STATUS_BAR_ITEMS] = {StrId::STR_HIDE, StrId::STR_BOTTOM, StrId::STR_TOP};
constexpr int STATUS_BAR_CLOCK_ITEMS = 3;
const StrId statusBarClockNames[STATUS_BAR_CLOCK_ITEMS] = {StrId::STR_HIDE, StrId::STR_DIR_RIGHT, StrId::STR_DIR_LEFT};
const int verticalPreviewPadding = 50;
const int verticalPreviewTextPadding = 40;
} // namespace
@@ -112,6 +115,10 @@ void StatusBarSettingsActivity::onEnter() {
SETTINGS.clockFormat = 0;
}
if (SETTINGS.statusBarClock >= STATUS_BAR_CLOCK_ITEMS) {
SETTINGS.statusBarClock = CrossPointSettings::STATUS_BAR_CLOCK_MODE::STATUS_BAR_CLOCK_HIDE;
}
requestUpdate();
}
@@ -176,7 +183,7 @@ void StatusBarSettingsActivity::handleSelection() {
SETTINGS.xtcStatusBarMode = (SETTINGS.xtcStatusBarMode + 1) % XTC_STATUS_BAR_ITEMS;
break;
case ITEM_CLOCK:
SETTINGS.statusBarClock = (SETTINGS.statusBarClock + 1) % 2;
SETTINGS.statusBarClock = (SETTINGS.statusBarClock + 1) % STATUS_BAR_CLOCK_ITEMS;
break;
case ITEM_CLOCK_FORMAT:
SETTINGS.clockFormat = (SETTINGS.clockFormat + 1) % CLOCK_FORMAT_ITEMS;
@@ -225,7 +232,7 @@ void StatusBarSettingsActivity::render(RenderLock&&) {
case ITEM_XTC_STATUS_BAR:
return I18N.get(xtcStatusBarNames[SETTINGS.xtcStatusBarMode]);
case ITEM_CLOCK:
return SETTINGS.statusBarClock ? tr(STR_SHOW) : tr(STR_HIDE);
return I18N.get(statusBarClockNames[SETTINGS.statusBarClock]);
case ITEM_CLOCK_FORMAT: {
const uint8_t fmt = SETTINGS.clockFormat < CLOCK_FORMAT_ITEMS ? SETTINGS.clockFormat : 0;
return std::string(I18N.get(clockFormatNames[fmt]));
@@ -99,7 +99,9 @@ bool KeyboardEntryActivity::handleKeyPress() {
case SpecialKeyType::Shift:
delPressCount = 0;
hintVisible = false;
if (urlMode || inputType == InputType::Url) return true;
// Shift is meaningless in the URL-snippet panel and the symbol layout, but
// must work for the URL letter layout so uppercase letters can be entered (#2178).
if (urlMode) return true;
if (symMode) return true;
shiftState = (shiftState + 1) % 2;
return true;
@@ -675,8 +677,8 @@ void KeyboardEntryActivity::render(RenderLock&&) {
const char* label;
};
const BottomKeyInfo bottomKeys[BOTTOM_KEY_COUNT] = {
{(symMode || urlMode || inputType == InputType::Url) ? KeyboardKeyType::Disabled : KeyboardKeyType::Shift,
(symMode || urlMode || inputType == InputType::Url) ? shiftString[0] : shiftString[shiftState]},
{(symMode || urlMode) ? KeyboardKeyType::Disabled : KeyboardKeyType::Shift,
(symMode || urlMode) ? shiftString[0] : shiftString[shiftState]},
{KeyboardKeyType::Mode, urlMode ? "abc" : (symMode ? "abc" : "#@!")},
{inputType == InputType::Url ? KeyboardKeyType::Mode : KeyboardKeyType::Space,
inputType == InputType::Url ? "URL" : nullptr},
+4 -3
View File
@@ -131,9 +131,10 @@ int UITheme::getStatusBarHeight() {
const ThemeMetrics& metrics = UITheme::getInstance().getMetrics();
// Add status bar margin
const bool showStatusBar = SETTINGS.statusBarChapterPageCount || SETTINGS.statusBarBookProgressPercentage ||
SETTINGS.statusBarTitle != CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE ||
SETTINGS.statusBarBattery;
const bool showStatusBar =
SETTINGS.statusBarChapterPageCount || SETTINGS.statusBarBookProgressPercentage ||
SETTINGS.statusBarTitle != CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE || SETTINGS.statusBarBattery ||
SETTINGS.statusBarClock != CrossPointSettings::STATUS_BAR_CLOCK_MODE::STATUS_BAR_CLOCK_HIDE;
const bool showProgressBar =
SETTINGS.statusBarProgressBar != CrossPointSettings::STATUS_BAR_PROGRESS_BAR::HIDE_PROGRESS;
return (showStatusBar ? (metrics.statusBarVerticalMargin) : 0) +
+5
View File
@@ -10,3 +10,8 @@ static const uint8_t BookmarkIcon[] = {
0x00, 0x00, 0x00, 0x07, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x01, 0xC0, 0x00, 0x00, 0x03, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
// size: 16x16
static const uint8_t BookmarkStatusIcon[] = {0x0F, 0xF8, 0x0F, 0xF8, 0x0F, 0xF8, 0x0F, 0xF8, 0x0F, 0xF8, 0x0F,
0xF8, 0x0F, 0xF8, 0x0F, 0xF8, 0x0F, 0xF8, 0x0F, 0xF8, 0x0F, 0xF8,
0x0F, 0x78, 0x0E, 0x38, 0x0C, 0x18, 0x08, 0x08, 0x00, 0x00};
+68 -20
View File
@@ -13,6 +13,7 @@
#include "I18n.h"
#include "RecentBooksStore.h"
#include "components/UITheme.h"
#include "components/icons/bookmark.h"
#include "fontIds.h"
// Internal constants
@@ -20,6 +21,27 @@ namespace {
constexpr int homeMenuMargin = 20;
constexpr int homeMarginTop = 30;
constexpr int subtitleY = 738;
constexpr int bookmarkStatusIconWidth = 16;
constexpr int bookmarkStatusIconHeight = 14;
constexpr int bookmarkStatusIconGap = 4;
constexpr int bookmarkStatusIconTopCrop = 2;
bool statusBarTextLaneVisible() {
return SETTINGS.statusBarChapterPageCount || SETTINGS.statusBarBookProgressPercentage ||
SETTINGS.statusBarTitle != CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE || SETTINGS.statusBarBattery ||
(SETTINGS.statusBarClock && halClock.isAvailable());
}
void drawBookmarkStatusIcon(const GfxRenderer& renderer, const int x, const int y) {
constexpr int bytesPerRow = bookmarkStatusIconWidth / 8;
for (int row = 0; row < bookmarkStatusIconHeight; ++row) {
for (int col = 0; col < bookmarkStatusIconWidth; ++col) {
const uint8_t byte = BookmarkStatusIcon[(row + bookmarkStatusIconTopCrop) * bytesPerRow + col / 8];
const uint8_t mask = 1U << (7 - (col % 8));
renderer.drawPixel(x + col, y + row, (byte & mask) != 0);
}
}
}
} // namespace
@@ -635,8 +657,8 @@ void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std:
// No book to continue reading
const int y =
bookY + (bookHeight - renderer.getLineHeight(UI_12_FONT_ID) - renderer.getLineHeight(UI_10_FONT_ID)) / 2;
renderer.drawCenteredText(UI_12_FONT_ID, y, "No open book");
renderer.drawCenteredText(UI_10_FONT_ID, y + renderer.getLineHeight(UI_12_FONT_ID), "Start reading below");
renderer.drawCenteredText(UI_12_FONT_ID, y, tr(STR_NO_OPEN_BOOK));
renderer.drawCenteredText(UI_10_FONT_ID, y + renderer.getLineHeight(UI_12_FONT_ID), tr(STR_START_READING));
}
}
@@ -727,16 +749,21 @@ void BaseTheme::fillPopupProgress(const GfxRenderer& renderer, const Rect& layou
void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, const int currentPage,
const int pageCount, std::string title, const int paddingBottom, const int textYOffset,
const bool fillMargin) const {
const bool fillMargin, const bool isPageBookmarked) const {
auto metrics = UITheme::getInstance().getMetrics();
int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft;
renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom,
&orientedMarginLeft);
const bool showStatusBarTextLane = statusBarTextLaneVisible();
// Draw Progress Text
const auto screenHeight = renderer.getScreenHeight();
auto textY = screenHeight - UITheme::getInstance().getStatusBarHeight() - orientedMarginBottom - paddingBottom - 4;
int progressTextWidth = 0;
const int leftClusterX = metrics.statusBarHorizontalMargin + orientedMarginLeft + 1;
const int rightClusterX = renderer.getScreenWidth() - metrics.statusBarHorizontalMargin - orientedMarginRight;
int leftClusterWidth = 0;
int rightClusterWidth = 0;
if (SETTINGS.statusBarBookProgressPercentage || SETTINGS.statusBarChapterPageCount) {
// Right aligned text for progress counter
@@ -750,11 +777,10 @@ void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, c
snprintf(progressStr, sizeof(progressStr), "%d/%d", currentPage, pageCount);
}
progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progressStr);
renderer.drawText(
SMALL_FONT_ID,
renderer.getScreenWidth() - metrics.statusBarHorizontalMargin - orientedMarginRight - progressTextWidth, textY,
progressStr);
int progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progressStr);
renderer.drawText(SMALL_FONT_ID, rightClusterX - progressTextWidth, textY, progressStr);
rightClusterWidth += progressTextWidth;
}
// Draw Progress Bar
@@ -780,26 +806,50 @@ void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, c
// Draw Battery
const bool showBatteryPercentage =
SETTINGS.hideBatteryPercentage == CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_NEVER;
if (SETTINGS.statusBarBattery) {
GUI.drawBatteryLeft(renderer,
Rect{metrics.statusBarHorizontalMargin + orientedMarginLeft + 1, textY, metrics.batteryWidth,
metrics.batteryHeight},
Rect{leftClusterX + leftClusterWidth, textY, metrics.batteryWidth, metrics.batteryHeight},
showBatteryPercentage);
int batteryWidth = metrics.batteryWidth;
if (showBatteryPercentage) {
const uint16_t percentage = powerManager.getBatteryPercentage();
// width of icon + spacing + text for layout purposes
batteryWidth +=
batteryPercentSpacing + renderer.getTextWidth(SMALL_FONT_ID, (std::to_string(percentage) + "%").c_str());
}
leftClusterWidth += batteryWidth;
}
// Draw Clock (X3 only — DS3231 RTC)
int clockTextWidth = 0;
if (SETTINGS.statusBarClock && halClock.isAvailable()) {
char timeBuf[9];
if (halClock.formatTime(timeBuf, sizeof(timeBuf), SETTINGS.clockUtcOffsetQ, SETTINGS.clockFormat == 1)) {
clockTextWidth = renderer.getTextWidth(SMALL_FONT_ID, timeBuf);
// Position to the left of the progress text (with a small gap)
const int clockX = renderer.getScreenWidth() - metrics.statusBarHorizontalMargin - orientedMarginRight -
progressTextWidth - (progressTextWidth > 0 ? 10 : 0) - clockTextWidth;
int clockTextWidth = renderer.getTextWidth(SMALL_FONT_ID, timeBuf);
int clockX = 0;
// Position to the left or right of the progress text (with a small gap)
if (SETTINGS.statusBarClock == CrossPointSettings::STATUS_BAR_CLOCK_LEFT) {
clockX = leftClusterX + leftClusterWidth + (leftClusterWidth > 0 ? 10 : 0);
leftClusterWidth += clockTextWidth + 10;
} else if (SETTINGS.statusBarClock == CrossPointSettings::STATUS_BAR_CLOCK_RIGHT) {
clockX = rightClusterX - rightClusterWidth - (rightClusterWidth > 0 ? 10 : 0) - clockTextWidth;
rightClusterWidth += clockTextWidth + 10;
}
renderer.drawText(SMALL_FONT_ID, clockX, textY, timeBuf);
}
}
// Draw Bookmark
if (showStatusBarTextLane && isPageBookmarked) {
const int bookmarkGap = leftClusterWidth > 0 ? bookmarkStatusIconGap : 0;
const int bookmarkX = leftClusterX + leftClusterWidth + bookmarkGap;
const int bookmarkY = textY + 5;
drawBookmarkStatusIcon(renderer, bookmarkX, bookmarkY);
leftClusterWidth += bookmarkStatusIconWidth + bookmarkGap;
}
// Draw Title
if (!title.empty()) {
textY -= textYOffset;
@@ -808,10 +858,8 @@ void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, c
const int rendererableScreenWidth =
renderer.getScreenWidth() - (metrics.statusBarHorizontalMargin * 2) - orientedMarginLeft - orientedMarginRight;
const int batterySize = SETTINGS.statusBarBattery ? (showBatteryPercentage ? 50 : 20) : 0;
const int titleMarginLeft = batterySize + 30;
const int clockReserve = clockTextWidth > 0 ? (clockTextWidth + 10) : 0;
const int titleMarginRight = progressTextWidth + clockReserve + 30;
const int titleMarginLeft = leftClusterWidth + 30;
const int titleMarginRight = rightClusterWidth + 30;
// Attempt to center title on the screen, but if title is too wide then later we will center it within the
// available space.
+6 -1
View File
@@ -32,6 +32,9 @@ struct ThemeMetrics {
int headerHeight;
int verticalSpacing;
int previewPadding;
int previewHeightPercent;
int contentSidePadding;
int listRowHeight;
int listWithSubtitleRowHeight;
@@ -111,6 +114,8 @@ constexpr ThemeMetrics values = {.batteryWidth = 15,
.batteryBarHeight = 20,
.headerHeight = 45,
.verticalSpacing = 10,
.previewPadding = 12,
.previewHeightPercent = 30,
.contentSidePadding = 20,
.listRowHeight = 30,
.listWithSubtitleRowHeight = 50,
@@ -205,7 +210,7 @@ class BaseTheme {
virtual void fillPopupProgress(const GfxRenderer& renderer, const Rect& layout, const int progress) const;
void drawStatusBar(GfxRenderer& renderer, const float bookProgress, const int currentPage, const int pageCount,
std::string title, const int paddingBottom = 0, const int textYOffset = 0,
const bool fillMargin = true) const;
const bool fillMargin = true, const bool isPageBookmarked = false) const;
void drawHelpText(const GfxRenderer& renderer, Rect rect, const char* label) const;
virtual void drawTextField(const GfxRenderer& renderer, Rect rect, const int textWidth, bool cursorMode = false,
int contentStartX = 0, int contentWidth = 0) const;
+2
View File
@@ -12,6 +12,8 @@ constexpr ThemeMetrics values = {.batteryWidth = 16,
.batteryBarHeight = 40,
.headerHeight = 84,
.verticalSpacing = 16,
.previewPadding = 12,
.previewHeightPercent = 30,
.contentSidePadding = 20,
.listRowHeight = 40,
.listWithSubtitleRowHeight = 60,
@@ -11,6 +11,8 @@ constexpr ThemeMetrics values = {.batteryWidth = 15,
.batteryBarHeight = 20,
.headerHeight = 45,
.verticalSpacing = 10,
.previewPadding = 12,
.previewHeightPercent = 30,
.contentSidePadding = 20,
.listRowHeight = 42,
.listWithSubtitleRowHeight = 69,
+1 -1
View File
@@ -34,8 +34,8 @@
#include "util/ButtonNavigator.h"
#include "util/ScreenshotUtil.h"
MappedInputManager mappedInputManager(gpio);
GfxRenderer renderer(display);
MappedInputManager mappedInputManager(gpio, renderer);
ActivityManager activityManager(renderer, mappedInputManager);
FontDecompressor fontDecompressor;
SdCardFontSystem sdFontSystem;
+131 -25
View File
@@ -80,11 +80,11 @@
}
.breadcrumb-inline .sep {
margin: 0 6px;
color: var(--border-color);
color: var(--label-color);
}
.breadcrumb-inline .current {
color: var(--title-color);
font-weight: 500;
font-weight: 600;
}
.nav-links {
margin: 20px 0;
@@ -291,6 +291,27 @@
.upload-form input[type="file"].has-files::file-selector-button {
background-color: #95a5a6;
}
.drop-zone {
border: 2px dashed var(--border-color);
border-radius: 6px;
padding: 14px;
text-align: center;
cursor: pointer;
transition: border-color 0.15s, background-color 0.15s;
}
.drop-zone.dragover {
border-color: var(--accent-color);
background-color: var(--accent-color-10);
}
.drop-zone-hint {
margin: 8px 0 0;
font-size: 0.85em;
color: var(--label-color);
pointer-events: none;
}
.drop-zone.dragover .drop-zone-hint {
color: var(--accent-color);
}
.upload-btn {
background-color: #27ae60;
color: white;
@@ -939,12 +960,6 @@
align-items: center;
margin-bottom: 12px;
}
.contents-title {
font-size: 1.1em;
font-weight: 600;
color: var(--title-color);
margin: 0;
}
.summary-inline {
color: var(--label-color);
font-size: 0.9em;
@@ -1275,9 +1290,6 @@
flex-wrap: wrap;
gap: 4px;
}
.contents-title {
font-size: 1em;
}
.summary-inline {
font-size: 0.8em;
}
@@ -1480,7 +1492,6 @@
<div class="page-header">
<div class="page-header-left">
<h2>📁 File Manager</h2>
<div class="breadcrumb-inline" id="directory-breadcrumbs"></div>
</div>
<div class="action-buttons">
@@ -1502,7 +1513,7 @@
<div class="card">
<div class="contents-header">
<h2 class="contents-title">Contents</h2>
<div class="breadcrumb-inline" id="directory-breadcrumbs"></div>
<span class="summary-inline" id="folder-summary"></span>
</div>
@@ -1526,7 +1537,10 @@
<h3>📤 Upload file</h3>
<div class="upload-form">
<p class="file-info">Select a file to upload to <strong id="uploadPathDisplay"></strong></p>
<input type="file" id="fileInput" onchange="validateFile()" multiple>
<div class="drop-zone" id="dropZone">
<input type="file" id="fileInput" onchange="validateFile()" multiple>
<p class="drop-zone-hint">⬇ Drop files here — or click to browse</p>
</div>
<div class="picker-columns" id="pickerColumns">
<div class="convert-options" id="convertOptions" style="display:none;">
<div class="convert-row">
@@ -1880,19 +1894,44 @@
const breadcrumbs = document.getElementById('directory-breadcrumbs');
const fileTable = document.getElementById('file-table');
let breadcrumbContent = '<span class="sep">/</span>';
if (currentPath === '/') {
breadcrumbContent += '<span class="current">🏠</span>';
const segments = currentPath.split('/').filter(Boolean);
breadcrumbs.replaceChildren();
const appendSep = function() {
const sep = document.createElement('span');
sep.className = 'sep';
sep.textContent = '';
breadcrumbs.appendChild(sep);
};
const appendLink = function(label, href) {
const link = document.createElement('a');
link.href = href;
link.textContent = label;
breadcrumbs.appendChild(link);
};
const appendCurrent = function(label) {
const current = document.createElement('span');
current.className = 'current';
current.textContent = label;
breadcrumbs.appendChild(current);
};
if (segments.length === 0) {
appendCurrent('🏠 Home');
} else {
breadcrumbContent += '<a href="/files">🏠</a>';
const pathSegments = currentPath.split('/');
pathSegments.slice(1, pathSegments.length - 1).forEach(function(segment, index) {
breadcrumbContent += '<span class="sep">/</span><a href="/files?path=' + encodeURIComponent(pathSegments.slice(0, index + 2).join('/')) + '">' + escapeHtml(segment) + '</a>';
appendLink('🏠 Home', '/files');
segments.forEach(function(segment, index) {
appendSep();
if (index === segments.length - 1) {
appendCurrent(segment);
} else {
const path = '/' + segments.slice(0, index + 1).join('/');
appendLink(segment, '/files?path=' + encodeURIComponent(path));
}
});
breadcrumbContent += '<span class="sep">/</span>';
breadcrumbContent += '<span class="current">' + escapeHtml(pathSegments[pathSegments.length - 1]) + '</span>';
}
breadcrumbs.innerHTML = breadcrumbContent;
let files = [];
try {
@@ -2024,6 +2063,7 @@
operationCancelled = false;
isUploadInProgress = false;
document.getElementById('uploadModalClose').classList.remove('disabled');
document.getElementById('fileInput').disabled = false;
const progressFill = document.getElementById('progress-fill');
const progressText = document.getElementById('progress-text');
progressFill.style.width = '0%';
@@ -2044,6 +2084,7 @@
const fileInput = document.getElementById('fileInput');
fileInput.value = '';
fileInput.classList.remove('has-files');
fileInput.disabled = false;
const uploadBtn = document.getElementById('uploadBtn');
uploadBtn.disabled = true;
uploadBtn.style.display = 'block';
@@ -3023,6 +3064,68 @@
}
}, 10);
});
// Drag-and-drop: route dropped files through the existing file input so the
// normal validateFile() pipeline (EPUB optimize options, batch mode, upload)
// runs unchanged. Assigning input.files via DataTransfer is supported in all
// current Chromium/Firefox/Safari, which is what the device serves the page to.
const dropZone = document.getElementById('dropZone');
if (dropZone) {
// dragenter/dragleave fire for child elements too; a counter avoids the
// highlight flickering as the cursor moves over the hint text or input.
let dragDepth = 0;
// An in-flight transfer can't be interrupted, so the zone is inert while
// uploading: no picker, no hover highlight — matching the disabled upload
// button and the drop handler's guard below.
const uploadBusy = () =>
typeof isUploadInProgress !== 'undefined' && isUploadInProgress;
// "click to browse" should work anywhere in the zone, not just on the
// native button. The hint has pointer-events:none so its clicks retarget
// to the zone; clicks on the native button retarget to fileInput (shadow
// DOM) and are skipped here so the picker isn't opened twice.
dropZone.addEventListener('click', function(e) {
if (uploadBusy()) return;
if (e.target !== fileInput) fileInput.click();
});
dropZone.addEventListener('dragenter', function(e) {
e.preventDefault();
dragDepth++;
if (!uploadBusy()) dropZone.classList.add('dragover');
});
dropZone.addEventListener('dragover', function(e) {
// Required so the browser doesn't navigate to / open the dropped file.
e.preventDefault();
});
dropZone.addEventListener('dragleave', function(e) {
e.preventDefault();
dragDepth = Math.max(0, dragDepth - 1);
if (dragDepth === 0) dropZone.classList.remove('dragover');
});
dropZone.addEventListener('drop', function(e) {
e.preventDefault();
dragDepth = 0;
dropZone.classList.remove('dragover');
// Don't let a drop disrupt an in-flight transfer.
if (uploadBusy()) return;
const dropped = e.dataTransfer && e.dataTransfer.files;
if (!dropped || dropped.length === 0) return;
// Replace the current selection, matching native file-picker semantics.
const dt = new DataTransfer();
for (const file of dropped) dt.items.add(file);
fileInput.files = dt.files;
validateFile();
});
}
})();
function validateFile() {
@@ -3070,7 +3173,7 @@
}
updateBatchModeUI(files.length > 1);
uploadBtn.disabled = false;
uploadBtn.disabled = isUploadInProgress;
} else {
updateBatchModeUI(false);
uploadBtn.disabled = true;
@@ -5043,6 +5146,8 @@ function uploadFileHTTP(file, onProgress, onComplete, onError) {
}
function uploadFile() {
if (isUploadInProgress) return;
const fileInput = document.getElementById('fileInput');
const files = Array.from(fileInput.files);
const convertEnabled = document.getElementById('convertBeforeUpload').checked;
@@ -5057,6 +5162,7 @@ function uploadFile() {
uploadGeneration++;
const myGeneration = uploadGeneration;
document.getElementById('uploadModalClose').classList.add('disabled');
fileInput.disabled = true;
const progressContainer = document.getElementById('progress-container');
const progressFill = document.getElementById('progress-fill');
+5 -7
View File
@@ -44,10 +44,8 @@ class ButtonNavigator final {
[[nodiscard]] static int nextPageIndex(int currentIndex, int totalItems, int itemsPerPage);
[[nodiscard]] static int previousPageIndex(int currentIndex, int totalItems, int itemsPerPage);
[[nodiscard]] static Buttons getNextButtons() {
return {MappedInputManager::Button::Down, MappedInputManager::Button::Right};
}
[[nodiscard]] static Buttons getPreviousButtons() {
return {MappedInputManager::Button::Up, MappedInputManager::Button::Left};
}
};
// Navigation uses the logical NavNext / NavPrevious buttons; MappedInputManager::mapButton resolves
// them to physical buttons and applies any orientation-based direction swap, so this stays settings-free.
[[nodiscard]] static Buttons getNextButtons() { return {MappedInputManager::Button::NavNext}; }
[[nodiscard]] static Buttons getPreviousButtons() { return {MappedInputManager::Button::NavPrevious}; }
};
+1
View File
@@ -42,3 +42,4 @@ add_subdirectory(streaming_json_parser)
add_subdirectory(release_json_parser)
add_subdirectory(differential_rounding)
add_subdirectory(hyphenation_eval)
add_subdirectory(utf8_compose)
+15
View File
@@ -0,0 +1,15 @@
add_executable(Utf8ComposeTest
Utf8ComposeTest.cpp
${REPO_ROOT}/lib/Utf8/Utf8.cpp
)
target_include_directories(Utf8ComposeTest PRIVATE
${REPO_ROOT}/lib/Utf8
)
target_link_libraries(Utf8ComposeTest PRIVATE
crosspoint_test_common
GTest::gtest_main
)
gtest_discover_tests(Utf8ComposeTest)
+54
View File
@@ -0,0 +1,54 @@
#include <gtest/gtest.h>
#include <string>
#include "Utf8.h"
namespace {
// Helpers to build NFD / expected byte sequences explicitly so the test does not
// depend on the encoding of this source file.
const std::string kCombGrave = "\xCC\x80"; // U+0300 COMBINING GRAVE ACCENT
const std::string kCombAcute = "\xCC\x81"; // U+0301 COMBINING ACUTE ACCENT
const std::string kCombCirc = "\xCC\x82"; // U+0302 COMBINING CIRCUMFLEX ACCENT
const std::string kCombDotBelow = "\xCC\xA3"; // U+0323 COMBINING DOT BELOW
} // namespace
// ASCII and already-precomposed (NFC) text must pass through untouched (fast path).
TEST(Utf8ComposeNfc, PassesThroughAsciiAndNfc) {
EXPECT_EQ(utf8ComposeNfc(""), "");
EXPECT_EQ(utf8ComposeNfc("hello world"), "hello world");
EXPECT_EQ(utf8ComposeNfc("caf\xC3\xA9"), "caf\xC3\xA9"); // é already U+00E9
}
// Single combining mark composes onto its base letter.
TEST(Utf8ComposeNfc, ComposesSingleMark) {
EXPECT_EQ(utf8ComposeNfc("e" + kCombAcute), "\xC3\xA9"); // e + ́ -> é (U+00E9)
EXPECT_EQ(utf8ComposeNfc("a" + kCombGrave), "\xC3\xA0"); // a + ̀ -> à (U+00E0)
}
// Vietnamese letters carry two stacked marks; composition must accumulate them
// onto the intermediate precomposed character (this is the crux of the feature).
TEST(Utf8ComposeNfc, ComposesStackedVietnameseMarks) {
// a + circumflex + acute -> ấ (U+1EA5)
EXPECT_EQ(utf8ComposeNfc("a" + kCombCirc + kCombAcute), "\xE1\xBA\xA5");
// a + dot-below + circumflex (canonical order) -> ậ (U+1EAD)
EXPECT_EQ(utf8ComposeNfc("a" + kCombDotBelow + kCombCirc), "\xE1\xBA\xAD");
}
// A combining mark with no composition for its base is left unchanged, and the
// base is preserved.
TEST(Utf8ComposeNfc, LeavesUncomposableMarksIntact) {
const std::string in = "q" + kCombAcute; // no precomposed "q with acute"
EXPECT_EQ(utf8ComposeNfc(in), in);
}
// A leading combining mark (no preceding base) is emitted unchanged.
TEST(Utf8ComposeNfc, HandlesLeadingMark) { EXPECT_EQ(utf8ComposeNfc(kCombAcute), kCombAcute); }
// Marks embedded in a longer word compose while surrounding text is preserved.
TEST(Utf8ComposeNfc, ComposesWithinWord) {
// "Ti" + e+circ+acute + "ng" -> "Tiếng"
EXPECT_EQ(utf8ComposeNfc("Ti" + std::string("e") + kCombCirc + kCombAcute + "ng"), "Ti\xE1\xBA\xBFng");
}