Public Access
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c35bc47693 | ||
|
|
4cdb4c342f | ||
|
|
1c9f90f92e | ||
|
|
2c18762614 | ||
|
|
ae25cf0be2 | ||
|
|
834c6eb0f2 | ||
|
|
5823584bfd |
@@ -138,7 +138,8 @@ A copy runs in the background of the card (about 400 KB a second) in short turns
|
|||||||
|
|
||||||
Enter on a file opens it by type; Tab switches to the same file as a hex dump or as text:
|
Enter on a file opens it by type; Tab switches to the same file as a hex dump or as text:
|
||||||
|
|
||||||
- **Text** (`.txt`, `.log`, `.gmi`, `.csv`, and anything that looks like text): only the screen's worth is read from the card, so a file of any size opens at once. Logs open at the end. Up and Down move a line, Left and Right a page, `t` and `b` go to the top and the end, `e` edits it (up to 16 KB, see Notes).
|
- **Text** (`.txt`, `.log`, `.gmi`, `.csv`, and anything that looks like text): only the screen's worth is read from the card, so a file of any size opens at once. Logs open at the end. Up and Down move a line, Left and Right a page, `t` and `b` go to the top and the end, `e` edits it, whatever its size (see Notes).
|
||||||
|
- **Pictures** (`.png`, `.jpg`, `.bmp`, `.gif`; issue #45): shrunk to fit the screen, or at their own size with Enter, the arrows then moving half a screen at a time; `i` gives the size in pixels. Dithered to the screen's 256 colours, decoded straight into the screen's buffer with no copy in memory, on the storage task so the keys keep working (12 megapixels of JPEG: 7 s). A GIF shows its first picture. A progressive JPEG or an interlaced PNG opens as hex, with the reason.
|
||||||
- **Captures** (`.pcap`): the packets as the LoRa Scanner lists them; Enter shows one with its Meshtastic header and bytes.
|
- **Captures** (`.pcap`): the packets as the LoRa Scanner lists them; Enter shows one with its Meshtastic header and bytes.
|
||||||
- **Tracks** (`.gpx`): the number of points, the start, the duration and the distance.
|
- **Tracks** (`.gpx`): the number of points, the start, the duration and the distance.
|
||||||
- **Update Files** (`.ota`): the version, and whether the file would install: it's checked as an install checks it (signature and contents) without writing anything. Enter then installs it.
|
- **Update Files** (`.ota`): the version, and whether the file would install: it's checked as an install checks it (signature and contents) without writing anything. Enter then installs it.
|
||||||
@@ -167,7 +168,7 @@ The Shell App (docs/milestones/S1.md) runs the commands below on the device's ow
|
|||||||
| Command | Effect |
|
| Command | Effect |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `burst` | Publishes 5 Notifications at once |
|
| `burst` | Publishes 5 Notifications at once |
|
||||||
| `key up\|down\|left\|right\|select\|back\|home\|del\|tab\|space\|help`, or `key <char>` | Injects a key press (`help` is Fn+h: the keys of the screen that is showing). `ctrl-`, `alt-` and `shift-` before it hold that key: `key ctrl-down`, `key alt-up`, `key ctrl-b` |
|
| `key up\|down\|left\|right\|select\|back\|home\|del\|tab\|space\|help\|shot`, or `key <char>` | Injects a key press (`help` is Fn+h: the keys of the screen that is showing; `shot` is Fn+p: a screenshot). `ctrl-`, `alt-` and `shift-` before it hold that key: `key ctrl-down`, `key alt-up`, `key ctrl-b` |
|
||||||
| `sound on` / `sound off` | Toggles the Sound setting (beep + LED) |
|
| `sound on` / `sound off` | Toggles the Sound setting (beep + LED) |
|
||||||
| `short` / `normal` | Screen timeouts 5 s / 10 s, or 30 s / 60 s |
|
| `short` / `normal` | Screen timeouts 5 s / 10 s, or 30 s / 60 s |
|
||||||
| `wifi add <ssid><TAB><password>` | Adds a Saved Network (so credentials stay out of the repo) |
|
| `wifi add <ssid><TAB><password>` | Adds a Saved Network (so credentials stay out of the repo) |
|
||||||
|
|||||||
@@ -216,3 +216,77 @@ Test notes were copied to `/notes` and removed afterwards; the note that was alr
|
|||||||
**Not checked:** the power button's path (side file only), the screen turning off, a card pulled while editing, and memory with IRC connected, which wasn't connected for these checks: the editor's own use hasn't changed, and it still refuses to open without a free block of 24 KB. The real keyboard's Ctrl with Fn and the arrows. A file of tens of megabytes. Renaming or deleting a note from the Storage App leaves its side file behind.
|
**Not checked:** the power button's path (side file only), the screen turning off, a card pulled while editing, and memory with IRC connected, which wasn't connected for these checks: the editor's own use hasn't changed, and it still refuses to open without a free block of 24 KB. The real keyboard's Ctrl with Fn and the arrows. A file of tens of megabytes. Renaming or deleting a note from the Storage App leaves its side file behind.
|
||||||
|
|
||||||
**Measured against what was said:** the first build rewrote 1.2 MB in 3.5 to 4.5 s, with 2 KB blocks. With 4 KB blocks it is 2.6 s, about 450 KB a second, which is what the card gives a plain copy.
|
**Measured against what was said:** the first build rewrote 1.2 MB in 3.5 to 4.5 s, with 2 KB blocks. With 4 KB blocks it is 2.6 s, about 450 KB a second, which is what the card gives a plain copy.
|
||||||
|
|
||||||
|
## Pictures in the Storage App (issue #45)
|
||||||
|
|
||||||
|
Q139 left images out: the firmware wrote none. Since the Shell's `screenshot` it does.
|
||||||
|
|
||||||
|
### Decisions (design round 2026-10-07)
|
||||||
|
|
||||||
|
| # | Decision |
|
||||||
|
|---|---|
|
||||||
|
| Q233 | **PNG, JPEG, BMP and GIF.** A GIF shows its first picture; it doesn't move. |
|
||||||
|
| Q234 | *Revised while building.* **Our own PNG decoder**, a row at a time, with the window the file's compression asks for: 32 KB at most. The plan was the display library's, which takes 44 KB in one block: after one picture the largest free block was 43 to 47 KB, and every second PNG was refused. **The firmware's own screenshots** are read with no decoding at all: they are stored uncompressed, each byte already a colour of the screen. |
|
||||||
|
| Q235 | **The picture is decoded once, straight into the screen's buffer, and left there.** No copy in memory (it would be up to 30 KB). `App::retainsContent()` tells the screen not to clear the App's part; `contentLost()` tells the App that it was cleared after all, or that a Toast or the help panel drawn over it has gone: then it is decoded again. |
|
||||||
|
| Q236 | **Shrunk to fit; Enter shows it at its own size**, the arrows then moving half a screen. A picture smaller than the screen sits in the middle at its size. |
|
||||||
|
| Q237 | **Ordered dithering** to the screen's 256 colours (a 4 x 4 pattern). A colour the screen has exactly comes out as itself wherever it lands, so a screenshot isn't touched. |
|
||||||
|
| Q238 | Shrinking takes, for each pixel of the screen, the first of the picture's that falls on it. No averaging: there is nowhere to keep the sums. Thin lines break up; a JPEG looks better, its decoder halving it up to three times first. |
|
||||||
|
| Q239 | **What can't be shown opens as hex, with the reason:** a progressive JPEG, an interlaced PNG, a BMP that is compressed or has 16 bits. The rotation a camera stores in the file is ignored. |
|
||||||
|
| Q240 | *Revised while building.* **Decoding runs on the storage task while the main loop goes on.** The picture appears as it comes, and anything that needs the screen back stops the decoding first. The plan was to wait for it, behind a "Decoding..." line: 12 megapixels took longer than the watchdog allows the main loop to stand still, and the device restarted. |
|
||||||
|
| Q241 | The Storage App: Enter on `.png`, `.jpg`, `.jpeg`, `.bmp`, `.gif`, or on a file with no known extension whose first bytes say what it is. Tab gives the hex. `i`, and opening, show the size in pixels on the last line for three seconds. |
|
||||||
|
| Q242 | Not in this one: animation, opening a picture from the Gemini App, a slideshow. |
|
||||||
|
|
||||||
|
### As built
|
||||||
|
|
||||||
|
- **`lib/files/src/image_file.h`** (host-tested): what a file is and how big, where each pixel lands (`ImageFrame`, `ImageMap`), the dithering, and readers for BMP and GIF that hand their pixels on as they get them. **`png_reader.h`**: the PNG decoder, with its own inflate: every colour type and bit depth, palettes with transparency. Transparent pixels are left as the background.
|
||||||
|
- **`ImagePane`** (`src/apps/image_pane`) is the view. One decoding is a `Job` shared with the storage task; `cancel()` flags it and waits behind it in the task's queue, which is how the screen is known to be free again.
|
||||||
|
- **JPEG is the one decoder that isn't ours:** the display library's TJpgDec, with its 3.9 KB pool. It shrinks by 2, 4 or 8 while decoding, which is why a photograph is possible at all.
|
||||||
|
- **A decoder stops early** once the rest of the file is below the screen (a picture at its own size), and a BMP's rows that aren't shown aren't read.
|
||||||
|
- **The note** on the last line is written over the picture; the strip under it (2.6 KB) is kept and put back, so showing it costs no decoding.
|
||||||
|
- **A BMP is read in the order its rows are stored**, last row first: reading it top to bottom meant going back through the file for every row, a second for 135 rows.
|
||||||
|
- **Cost:** 21 KB of flash. Nothing while no picture is shown.
|
||||||
|
|
||||||
|
### Measured on the device
|
||||||
|
|
||||||
|
| Picture | Fitted | Its own size |
|
||||||
|
|---|---|---|
|
||||||
|
| A screenshot of ours, 240 x 135 | 80 ms | 78 ms |
|
||||||
|
| PNG, 800 x 600 | 855 ms | 575 ms |
|
||||||
|
| PNG with transparency, 800 x 600 | 1,098 ms | |
|
||||||
|
| JPEG, 800 x 600 | 305 ms | |
|
||||||
|
| JPEG, 4000 x 3000 (2.6 MB) | 6.9 s | 7.7 s (the middle of it) |
|
||||||
|
| GIF, 800 x 600 | 642 ms | |
|
||||||
|
| BMP, 800 x 600 (1.4 MB) | 991 ms | |
|
||||||
|
| BMP, 240 x 135 | 124 ms | |
|
||||||
|
|
||||||
|
Free memory fell to 48.8 KB at the lowest while a PNG was decoded, from 104 KB. The storage task's stack: 3.2 KB never used, of 6.
|
||||||
|
|
||||||
|
### Host tests (20, `test/test_image_file` and `test/test_png_reader`)
|
||||||
|
|
||||||
|
The pictures in them were made with Pillow, so the readers are checked against an encoder that isn't ours: GIFs plain, interlaced, transparent and long enough for the codes to reach 12 bits; BMPs of 8 and 24 bits; PNGs of every kind (colour, with alpha, palette of 8 and 4 bits with a transparent entry, greys of 1, 8 and 16 bits, grey with alpha, not compressed, and one that refers 21,600 bytes back). Every reader is also fed its file with a byte changed, at every few bytes: an answer each time, and no pixel outside the picture.
|
||||||
|
|
||||||
|
### Checks on the device (2026-10-07, driven over the Debug Console)
|
||||||
|
|
||||||
|
Test pictures were copied to a scratch folder and removed afterwards, with the test screenshot.
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|---|---|
|
||||||
|
| Colour bars as PNG, JPEG, BMP and GIF, 240 x 135 and 800 x 600 | The same picture each time, the colours in the right order |
|
||||||
|
| A PNG with a transparent square | The square is the background |
|
||||||
|
| A screenshot taken in the Shell | Shown; at its own size it is the screen, pixel for pixel |
|
||||||
|
| A progressive JPEG | Hex, with "A progressive JPEG can't be shown" |
|
||||||
|
| An animated GIF | Its first picture |
|
||||||
|
| 12 megapixels | Arrives from the top down in 6.9 s; the size is noted when it is whole |
|
||||||
|
| Enter, then the arrows | Its own size from the middle, then half a screen at a time |
|
||||||
|
| Back in the middle of a decoding | The folder's listing at once |
|
||||||
|
| Tab to hex and back, three times; the help panel, then closed | The picture again each time |
|
||||||
|
|
||||||
|
**Not checked:** a photograph from a real camera (the test JPEGs were made by Pillow); a Toast over a picture; a PNG with IRC connected, when there may not be the memory; the card pulled while decoding; the real keyboard.
|
||||||
|
|
||||||
|
### What went wrong while building it
|
||||||
|
|
||||||
|
**The watchdog.** The first version waited for the decoder. The main loop is watched: five seconds without a pass and the device restarts, which is what it did on the 12-megapixel test. The crash report named the decoder's line. The decoding moved to the background, which also made the picture appear as it comes.
|
||||||
|
|
||||||
|
**A decoder that worked once.** The library's PNG decoder showed the first picture and refused the next five: "no memory". It wants 44 KB in one piece, and after some use the largest piece is 43 to 47 KB. Writing a decoder that needs 32 KB was less work than it sounds, and unlike the library's it has tests.
|
||||||
|
|
||||||
|
**Two sentences still said "up to 16 KB"** about editing, in the README and the Storage guide, after issue #47 lifted that. Corrected here.
|
||||||
|
|||||||
@@ -44,3 +44,18 @@ The lists first lived in each App's `help()`, as code. They are now **data, in o
|
|||||||
Three rows lost their second wording on the way, since a table is constant: GNSS's `Tab` and `r`, and the Scanner's `c`, now say both things they do ("record a Track, or stop it") instead of the one that applies. The guide pages keep their written tables too, where they say more than a key list can; those can still drift, and the generated ones under them are the reference.
|
Three rows lost their second wording on the way, since a table is constant: GNSS's `Tab` and `r`, and the Scanner's `c`, now say both things they do ("record a Track, or stop it") instead of the one that applies. The guide pages keep their written tables too, where they say more than a key list can; those can still drift, and the generated ones under them are the reference.
|
||||||
|
|
||||||
**Not checked:** the real Fn+h and `?` on the keyboard (the mapper is host-tested; the device was driven with `key help`); the Setup screens, which only a device that was never set up shows, so their new text has not been seen on a screen; and the states that need something to happen first (a dialog, a copy in progress, a Gemini prompt, a packet's details): their lists were read against the key handling, not looked at.
|
**Not checked:** the real Fn+h and `?` on the keyboard (the mapper is host-tested; the device was driven with `key help`); the Setup screens, which only a device that was never set up shows, so their new text has not been seen on a screen; and the states that need something to happen first (a dialog, a copy in progress, a Gemini prompt, a packet's details): their lists were read against the key handling, not looked at.
|
||||||
|
|
||||||
|
## The screenshot key (issue #83)
|
||||||
|
|
||||||
|
A screenshot could only be taken by typing `screenshot` in the Shell, where "now" is a picture of the Shell.
|
||||||
|
|
||||||
|
- **Fn+p, on every screen**, text fields included, saves the screen as it is (a dialog, the help panel or a Toast if one is showing) as a PNG in `/screenshots`, the way the Shell's command does. A Toast says so once the file is written, so it is never in the picture.
|
||||||
|
- **It never reaches an App.** `Key::Screenshot` comes out of the key mapper and is handled before the App manager, so the help panel stays open and a dialog keeps its selection.
|
||||||
|
- **Not on Settings > Debug Console:** that page shows the token, and a picture of it is a copy of the token in a file. `App::showsSecret()` says so, and the key answers with a Toast instead.
|
||||||
|
- **No card:** a Toast says so.
|
||||||
|
- No setting to switch it off: Fn with a letter isn't pressed by accident.
|
||||||
|
- It is in the "Everywhere" group of the help panel, and so in the website's key tables. `key shot` presses it over the consoles.
|
||||||
|
|
||||||
|
**Checked on the device** (2026-10-07, with `key shot`): in the Launcher, a 33,383-byte PNG appears in `/screenshots` and is the Launcher; with the help panel open, the picture is of the panel (which now lists Fn p) and the panel stays open; on Settings > Debug Console, no file is written; back on the Settings list, one is. The test pictures were removed.
|
||||||
|
|
||||||
|
**Not checked:** the real Fn+p on the keyboard (the mapper is host-tested); the two Toasts that refuse, which weren't looked at (one of them is on the page that mustn't be photographed); a device with no card.
|
||||||
|
|||||||
@@ -168,3 +168,17 @@ Q178 left publishing to the maintainer: a merge, then a command typed on the web
|
|||||||
| No secrets at all; only one of the four | Does nothing and says so; fails and says which are needed |
|
| No secrets at all; only one of the four | Does nothing and says so; fails and says which are needed |
|
||||||
|
|
||||||
**Not checked:** the real web server and the runner, which wait for the key to be installed: whether the runner reaches the server's SSH port is the first thing the first run will tell. Port forwarding, which `restrict` switches off, was not tried. `from=` was not tried either.
|
**Not checked:** the real web server and the runner, which wait for the key to be installed: whether the runner reaches the server's SSH port is the first thing the first run will tell. Port forwarding, which `restrict` switches off, was not tried. `from=` was not tried either.
|
||||||
|
|
||||||
|
## Search (issue #60)
|
||||||
|
|
||||||
|
A search over the documentation: the user guide, the how-tos, the questions and answers, and the developer docs. Not the devlog.
|
||||||
|
|
||||||
|
- **The index is the search page itself** (`/search/`, `templates/search.html`): one list item for each page and for each `##` heading of it, with that part's text, written by Zola from the pages' own content when the site is built. Nothing is fetched and nothing typed leaves the browser, so the Content-Security-Policy needs nothing new, and the web server still only runs `zola build`.
|
||||||
|
- **Without JavaScript** the page is a list of every page and heading of the documentation, each a link.
|
||||||
|
- **With it**, `js/search.js` filters and ranks the items as you type: every word has to be in the part; a word in a heading counts for most, the words side by side for more than scattered, and the user guide, the how-tos and the FAQ come before the developer docs, the milestones last. A result links to its heading, with the text around the match.
|
||||||
|
- **The content pages get no script for it:** the navigation has a link, and the index pages of the guide, the how-tos and the developer docs have a box that is a plain form to `/search/?q=`.
|
||||||
|
- **Size:** about 245 items, about 310 KB of HTML, under 100 KB compressed, loaded only by who searches.
|
||||||
|
|
||||||
|
**Checked** in Chromium with the production Content-Security-Policy on every response, no violation: "probation" (the guide's "Probation and Rollback" first), "safe mode", "rm -r", "how big can a note" (the FAQ's question first), a word that isn't there; typing, following a result to its heading, the box on the guide's index, 390 px wide with no sideways scroll, and JavaScript off. `check_site.py` follows every link of the page, so an index entry can't point at a heading that doesn't exist.
|
||||||
|
|
||||||
|
**Not checked:** other browsers, and a screen reader.
|
||||||
|
|||||||
@@ -39,6 +39,17 @@ class App {
|
|||||||
|
|
||||||
virtual void draw(Canvas& canvas) = 0;
|
virtual void draw(Canvas& canvas) = 0;
|
||||||
|
|
||||||
|
// True while the screen shows something a picture of it shouldn't hold: the screenshot key
|
||||||
|
// (Fn+p, issue #83) then refuses, and says so.
|
||||||
|
virtual bool showsSecret() const { return false; }
|
||||||
|
|
||||||
|
// An App whose screen is costly to draw again (a picture decoded from the card, issue #45) can
|
||||||
|
// keep what it drew: while this is true its part of the screen isn't cleared before draw(),
|
||||||
|
// which then draws only what changed. contentLost() says that it was cleared after all, or
|
||||||
|
// that something drawn over it has gone: everything has to be drawn again.
|
||||||
|
virtual bool retainsContent() const { return false; }
|
||||||
|
virtual void contentLost() {}
|
||||||
|
|
||||||
void requestRedraw() { redraw_ = true; }
|
void requestRedraw() { redraw_ = true; }
|
||||||
|
|
||||||
bool consumeRedraw() {
|
bool consumeRedraw() {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ inline constexpr KeyHelp kEverywhere[] = {
|
|||||||
{"Fn `", "home, the Launcher"},
|
{"Fn `", "home, the Launcher"},
|
||||||
{"; . , /", "arrows (Fn+ while typing)"},
|
{"; . , /", "arrows (Fn+ while typing)"},
|
||||||
{"Fn h ?", "these keys (? not typing)"},
|
{"Fn h ?", "these keys (? not typing)"},
|
||||||
|
{"Fn p", "a screenshot, on the card"},
|
||||||
};
|
};
|
||||||
|
|
||||||
// dialog: A question
|
// dialog: A question
|
||||||
@@ -292,6 +293,14 @@ inline constexpr KeyHelp kViewerOta[] = {
|
|||||||
{"Tab", "the file as hex"},
|
{"Tab", "the file as hex"},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// viewer-image: A picture
|
||||||
|
inline constexpr KeyHelp kViewerImage[] = {
|
||||||
|
{"Enter", "its own size, or all of it"},
|
||||||
|
{"; . , /", "move around it"},
|
||||||
|
{"i", "its size"},
|
||||||
|
{"Tab", "the file as hex"},
|
||||||
|
};
|
||||||
|
|
||||||
// notes: Notes, the list
|
// notes: Notes, the list
|
||||||
inline constexpr KeyHelp kNotes[] = {
|
inline constexpr KeyHelp kNotes[] = {
|
||||||
{"; .", "up, down"},
|
{"; .", "up, down"},
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ enum class Key : uint8_t {
|
|||||||
Tab,
|
Tab,
|
||||||
Delete,
|
Delete,
|
||||||
Help, // Fn+h anywhere, or ? outside Text Entry: the keys of this screen (issue #69)
|
Help, // Fn+h anywhere, or ? outside Text Entry: the keys of this screen (issue #69)
|
||||||
|
Screenshot, // Fn+p anywhere: the screen as a PNG on the card (issue #83). Never reaches an App
|
||||||
};
|
};
|
||||||
|
|
||||||
struct KeyEvent {
|
struct KeyEvent {
|
||||||
|
|||||||
@@ -0,0 +1,464 @@
|
|||||||
|
#include "image_file.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstring>
|
||||||
|
#include <memory>
|
||||||
|
#include <new>
|
||||||
|
|
||||||
|
#include "file_names.h"
|
||||||
|
#include "png_rgb332.h"
|
||||||
|
|
||||||
|
namespace roro::files {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
uint32_t le16(const uint8_t* p) { return p[0] | (p[1] << 8); }
|
||||||
|
uint32_t le32(const uint8_t* p) { return p[0] | (p[1] << 8) | (p[2] << 16) | (static_cast<uint32_t>(p[3]) << 24); }
|
||||||
|
uint32_t be16(const uint8_t* p) { return (p[0] << 8) | p[1]; }
|
||||||
|
uint32_t be32(const uint8_t* p) { return (static_cast<uint32_t>(p[0]) << 24) | (p[1] << 16) | (p[2] << 8) | p[3]; }
|
||||||
|
|
||||||
|
constexpr int kMaxSide = 16384; // more than that isn't a picture for this screen
|
||||||
|
const uint8_t kPngSignature[] = {0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n'};
|
||||||
|
|
||||||
|
// A file read from its start on, a small block at a time.
|
||||||
|
class Stream {
|
||||||
|
public:
|
||||||
|
Stream(const ImageRead& read, uint32_t size) : read_(read), size_(size) {}
|
||||||
|
int get() {
|
||||||
|
if (at_ >= have_) {
|
||||||
|
if (next_ >= size_) return -1;
|
||||||
|
have_ = read_(next_, buf_, std::min<size_t>(sizeof buf_, size_ - next_));
|
||||||
|
at_ = 0;
|
||||||
|
next_ += static_cast<uint32_t>(have_);
|
||||||
|
if (!have_) return -1;
|
||||||
|
}
|
||||||
|
return buf_[at_++];
|
||||||
|
}
|
||||||
|
bool take(uint8_t* into, size_t len) {
|
||||||
|
for (size_t i = 0; i < len; i++) {
|
||||||
|
int c = get();
|
||||||
|
if (c < 0) return false;
|
||||||
|
into[i] = static_cast<uint8_t>(c);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
bool skip(size_t len) {
|
||||||
|
size_t buffered = std::min(len, have_ - at_);
|
||||||
|
at_ += buffered;
|
||||||
|
len -= buffered;
|
||||||
|
if (len > size_ - next_) return false;
|
||||||
|
next_ += static_cast<uint32_t>(len);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
const ImageRead& read_;
|
||||||
|
uint32_t size_, next_ = 0;
|
||||||
|
uint8_t buf_[256];
|
||||||
|
size_t have_ = 0, at_ = 0;
|
||||||
|
};
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
ImageKind imageKindOfName(const std::string& name) {
|
||||||
|
std::string ext = extensionOf(name);
|
||||||
|
if (ext == "png") return ImageKind::Png;
|
||||||
|
if (ext == "jpg" || ext == "jpeg") return ImageKind::Jpeg;
|
||||||
|
if (ext == "bmp") return ImageKind::Bmp;
|
||||||
|
if (ext == "gif") return ImageKind::Gif;
|
||||||
|
return ImageKind::None;
|
||||||
|
}
|
||||||
|
|
||||||
|
ImageKind imageKindOfBytes(const uint8_t* head, size_t len) {
|
||||||
|
if (len >= 8 && std::memcmp(head, kPngSignature, 8) == 0) return ImageKind::Png;
|
||||||
|
if (len >= 3 && head[0] == 0xFF && head[1] == 0xD8 && head[2] == 0xFF) return ImageKind::Jpeg;
|
||||||
|
if (len >= 6 && (std::memcmp(head, "GIF87a", 6) == 0 || std::memcmp(head, "GIF89a", 6) == 0)) return ImageKind::Gif;
|
||||||
|
if (len >= 2 && head[0] == 'B' && head[1] == 'M') return ImageKind::Bmp;
|
||||||
|
return ImageKind::None;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* imageKindName(ImageKind kind) {
|
||||||
|
switch (kind) {
|
||||||
|
case ImageKind::Png: return "PNG";
|
||||||
|
case ImageKind::Jpeg: return "JPEG";
|
||||||
|
case ImageKind::Bmp: return "BMP";
|
||||||
|
case ImageKind::Gif: return "GIF";
|
||||||
|
default: return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string imageInfo(const ImageRead& read, uint32_t size, ImageInfo& out) {
|
||||||
|
uint8_t head[32];
|
||||||
|
size_t n = read(0, head, std::min<size_t>(sizeof head, size));
|
||||||
|
out.kind = imageKindOfBytes(head, n);
|
||||||
|
long w = 0, h = 0;
|
||||||
|
switch (out.kind) {
|
||||||
|
case ImageKind::Png:
|
||||||
|
if (n < 24 || std::memcmp(head + 12, "IHDR", 4) != 0) return "This PNG is damaged";
|
||||||
|
w = static_cast<long>(be32(head + 16));
|
||||||
|
h = static_cast<long>(be32(head + 20));
|
||||||
|
break;
|
||||||
|
case ImageKind::Gif:
|
||||||
|
if (n < 10) return "This GIF is damaged";
|
||||||
|
w = static_cast<long>(le16(head + 6));
|
||||||
|
h = static_cast<long>(le16(head + 8));
|
||||||
|
break;
|
||||||
|
case ImageKind::Bmp: {
|
||||||
|
if (n < 26) return "This BMP is damaged";
|
||||||
|
uint32_t dib = le32(head + 14);
|
||||||
|
if (dib < 40) return "This kind of BMP can't be shown";
|
||||||
|
w = static_cast<int32_t>(le32(head + 18));
|
||||||
|
h = static_cast<int32_t>(le32(head + 22));
|
||||||
|
if (h < 0) h = -h; // top row first
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case ImageKind::Jpeg: {
|
||||||
|
// Marker after marker until the one that carries the size.
|
||||||
|
uint32_t at = 2;
|
||||||
|
for (int guard = 0; guard < 4000; guard++) {
|
||||||
|
uint8_t m[9];
|
||||||
|
if (read(at, m, 4) != 4 || m[0] != 0xFF) return "This JPEG is damaged";
|
||||||
|
uint8_t marker = m[1];
|
||||||
|
if (marker == 0xFF) { // padding
|
||||||
|
at++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (marker == 0x01 || (marker >= 0xD0 && marker <= 0xD8)) { // no length
|
||||||
|
at += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (marker == 0xD9 || marker == 0xDA) return "This JPEG is damaged"; // the picture, and no size yet
|
||||||
|
bool frame = marker >= 0xC0 && marker <= 0xCF && marker != 0xC4 && marker != 0xC8 && marker != 0xCC;
|
||||||
|
if (frame) {
|
||||||
|
if (read(at, m, 9) != 9) return "This JPEG is damaged";
|
||||||
|
h = static_cast<long>(be16(m + 5));
|
||||||
|
w = static_cast<long>(be16(m + 7));
|
||||||
|
if (marker == 0xC2) return "A progressive JPEG can't be shown";
|
||||||
|
if (marker != 0xC0 && marker != 0xC1) return "This kind of JPEG can't be shown";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
at += 2 + be16(m + 2);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: return "Not a picture this can show";
|
||||||
|
}
|
||||||
|
if (w <= 0 || h <= 0) return "This picture is damaged";
|
||||||
|
if (w > kMaxSide || h > kMaxSide) return "Too big: 16,384 pixels a side at most";
|
||||||
|
out.width = static_cast<int>(w);
|
||||||
|
out.height = static_cast<int>(h);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t screenshotPixelsAt(const ImageRead& read, uint32_t size, int width, int height) {
|
||||||
|
if (width <= 0 || height <= 0 || size != png::Rgb332Writer::fileSize(width, height)) return 0;
|
||||||
|
// Signature, IHDR, then a palette of 256 colours, then the one IDAT: a zlib header and a
|
||||||
|
// single stored block.
|
||||||
|
uint8_t ihdr[5], plte[8], idat[15];
|
||||||
|
constexpr uint32_t kPlteAt = 8 + 25, kIdatAt = kPlteAt + 12 + 768;
|
||||||
|
if (read(24, ihdr, 5) != 5 || ihdr[0] != 8 || ihdr[1] != 3 || ihdr[4] != 0) return 0;
|
||||||
|
if (read(kPlteAt, plte, 8) != 8 || be32(plte) != 768 || std::memcmp(plte + 4, "PLTE", 4) != 0) return 0;
|
||||||
|
if (read(kIdatAt, idat, 15) != 15 || std::memcmp(idat + 4, "IDAT", 4) != 0) return 0;
|
||||||
|
uint32_t raw = static_cast<uint32_t>(width + 1) * static_cast<uint32_t>(height);
|
||||||
|
if (idat[8] != 0x78 || idat[10] != 0x01 || le16(idat + 11) != raw || le16(idat + 13) != (raw ^ 0xFFFF)) return 0;
|
||||||
|
return kIdatAt + 15 + 1; // past the first row's filter byte
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t rgb332Dithered(uint8_t r, uint8_t g, uint8_t b, int x, int y) {
|
||||||
|
static const uint8_t kBayer[16] = {0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5};
|
||||||
|
int threshold = kBayer[((y & 3) << 2) | (x & 3)] * 16 + 8; // 8 to 248
|
||||||
|
auto level = [threshold](int v, int top) {
|
||||||
|
int nearest = (v * top + 127) / 255;
|
||||||
|
if (nearest * 255 / top == v) return nearest; // a colour the screen has
|
||||||
|
int low = v * top / 255, rest = v * top - low * 255;
|
||||||
|
return rest > threshold ? low + 1 : low;
|
||||||
|
};
|
||||||
|
return static_cast<uint8_t>((level(r, 7) << 5) | (level(g, 7) << 2) | level(b, 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ImageMap::at(int sx, int sy, int& tx, int& ty) const {
|
||||||
|
if (sx < 0 || sy < 0) return false;
|
||||||
|
if (scale >= 65536) {
|
||||||
|
tx = sx + offX;
|
||||||
|
ty = sy + offY;
|
||||||
|
} else {
|
||||||
|
uint64_t fx = static_cast<uint64_t>(sx) * scale, fy = static_cast<uint64_t>(sy) * scale;
|
||||||
|
if ((fx & 0xFFFF) >= scale || (fy & 0xFFFF) >= scale) return false;
|
||||||
|
tx = static_cast<int>(fx >> 16) + offX;
|
||||||
|
ty = static_cast<int>(fy >> 16) + offY;
|
||||||
|
}
|
||||||
|
if (tx < 0 || ty < 0 || tx >= viewW || ty >= viewH) return false;
|
||||||
|
tx += viewX;
|
||||||
|
ty += viewY;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ImageMap::rowUsed(int sy) const {
|
||||||
|
if (sy < 0) return false;
|
||||||
|
int ty;
|
||||||
|
if (scale >= 65536) {
|
||||||
|
ty = sy + offY;
|
||||||
|
} else {
|
||||||
|
uint64_t fy = static_cast<uint64_t>(sy) * scale;
|
||||||
|
if ((fy & 0xFFFF) >= scale) return false;
|
||||||
|
ty = static_cast<int>(fy >> 16) + offY;
|
||||||
|
}
|
||||||
|
return ty >= 0 && ty < viewH;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ImageMap::below(int sy) const {
|
||||||
|
if (sy < 0) return false;
|
||||||
|
int ty = scale >= 65536 ? sy + offY : static_cast<int>((static_cast<uint64_t>(sy) * scale) >> 16) + offY;
|
||||||
|
return ty >= viewH;
|
||||||
|
}
|
||||||
|
|
||||||
|
ImageFrame::ImageFrame(int width, int height, int viewX, int viewY, int viewW, int viewH)
|
||||||
|
: w_(std::max(1, width)), h_(std::max(1, height)), vx_(viewX), vy_(viewY), vw_(std::max(1, viewW)), vh_(std::max(1, viewH)) {}
|
||||||
|
|
||||||
|
uint32_t ImageFrame::fitScale() const {
|
||||||
|
uint64_t sx = (static_cast<uint64_t>(vw_) << 16) / static_cast<uint64_t>(w_), sy = (static_cast<uint64_t>(vh_) << 16) / static_cast<uint64_t>(h_);
|
||||||
|
return static_cast<uint32_t>(std::min<uint64_t>(65536, std::max<uint64_t>(1, std::min(sx, sy))));
|
||||||
|
}
|
||||||
|
|
||||||
|
void ImageFrame::toggle() {
|
||||||
|
if (!bigger()) return;
|
||||||
|
actual_ = !actual_;
|
||||||
|
if (actual_) { // the middle of it first
|
||||||
|
panX_ = std::max(0, (w_ - vw_) / 2);
|
||||||
|
panY_ = std::max(0, (h_ - vh_) / 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ImageFrame::pan(int dx, int dy) {
|
||||||
|
if (!actual_) return false;
|
||||||
|
int x = std::clamp(panX_ + dx * (vw_ / 2), 0, std::max(0, w_ - vw_));
|
||||||
|
int y = std::clamp(panY_ + dy * (vh_ / 2), 0, std::max(0, h_ - vh_));
|
||||||
|
bool moved = x != panX_ || y != panY_;
|
||||||
|
panX_ = x;
|
||||||
|
panY_ = y;
|
||||||
|
return moved;
|
||||||
|
}
|
||||||
|
|
||||||
|
int ImageFrame::percent() const { return actual_ ? 100 : static_cast<int>((static_cast<uint64_t>(fitScale()) * 100 + 32768) >> 16); }
|
||||||
|
|
||||||
|
int ImageFrame::jpegShrink() const {
|
||||||
|
if (actual_) return 0;
|
||||||
|
uint32_t scale = fitScale();
|
||||||
|
int shrink = 0;
|
||||||
|
while (shrink < 3 && (static_cast<uint64_t>(scale) << (shrink + 1)) <= 65536) shrink++;
|
||||||
|
return shrink;
|
||||||
|
}
|
||||||
|
|
||||||
|
ImageMap ImageFrame::map(int shrink) const {
|
||||||
|
ImageMap m;
|
||||||
|
m.viewX = vx_;
|
||||||
|
m.viewY = vy_;
|
||||||
|
m.viewW = vw_;
|
||||||
|
m.viewH = vh_;
|
||||||
|
if (actual_) {
|
||||||
|
m.scale = 65536;
|
||||||
|
m.offX = w_ <= vw_ ? (vw_ - w_) / 2 : -panX_;
|
||||||
|
m.offY = h_ <= vh_ ? (vh_ - h_) / 2 : -panY_;
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
uint32_t scale = fitScale();
|
||||||
|
int tw = std::max<int>(1, static_cast<int>((static_cast<uint64_t>(w_) * scale) >> 16));
|
||||||
|
int th = std::max<int>(1, static_cast<int>((static_cast<uint64_t>(h_) * scale) >> 16));
|
||||||
|
m.offX = (vw_ - tw) / 2;
|
||||||
|
m.offY = (vh_ - th) / 2;
|
||||||
|
m.scale = static_cast<uint32_t>(std::min<uint64_t>(65536, static_cast<uint64_t>(scale) << shrink));
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string readBmp(const ImageRead& read, uint32_t size, const ImagePixels& pixels, const std::function<bool(int y)>& rowNeeded) {
|
||||||
|
uint8_t head[54];
|
||||||
|
if (read(0, head, sizeof head) != sizeof head || head[0] != 'B' || head[1] != 'M') return "This BMP is damaged";
|
||||||
|
uint32_t dataAt = le32(head + 10), dib = le32(head + 14), compression = le32(head + 30), colours = le32(head + 46);
|
||||||
|
int32_t w = static_cast<int32_t>(le32(head + 18)), h = static_cast<int32_t>(le32(head + 22));
|
||||||
|
uint32_t bits = le16(head + 28);
|
||||||
|
bool topDown = h < 0;
|
||||||
|
if (topDown) h = -h;
|
||||||
|
if (dib < 40 || w <= 0 || h <= 0 || w > kMaxSide || h > kMaxSide) return "This kind of BMP can't be shown";
|
||||||
|
if ((bits != 8 && bits != 24 && bits != 32) || (compression != 0 && !(compression == 3 && bits == 32))) return "This kind of BMP can't be shown";
|
||||||
|
std::unique_ptr<uint8_t[]> palette;
|
||||||
|
if (bits == 8) {
|
||||||
|
if (!colours || colours > 256) colours = 256;
|
||||||
|
palette.reset(new (std::nothrow) uint8_t[1024]());
|
||||||
|
if (!palette) return "Not enough memory";
|
||||||
|
if (read(14 + dib, palette.get(), colours * 4) != colours * 4) return "This BMP is damaged";
|
||||||
|
}
|
||||||
|
uint32_t bytes = bits / 8, rowSize = (static_cast<uint32_t>(w) * bytes + 3) & ~3u;
|
||||||
|
if (static_cast<uint64_t>(dataAt) + static_cast<uint64_t>(rowSize) * static_cast<uint32_t>(h) > size) return "This BMP is cut short";
|
||||||
|
// A row in one read when it fits, a piece of it at a time otherwise.
|
||||||
|
constexpr int kOut = 64; // pixels handed on at once
|
||||||
|
constexpr uint32_t kRowBuffer = 4096; // bytes
|
||||||
|
uint32_t rowBytes = static_cast<uint32_t>(w) * bytes, bufSize = std::min(rowBytes, kRowBuffer);
|
||||||
|
bufSize -= bufSize % bytes;
|
||||||
|
std::unique_ptr<uint8_t[]> in(new (std::nothrow) uint8_t[bufSize]);
|
||||||
|
if (!in) return "Not enough memory";
|
||||||
|
uint8_t out[kOut * 3];
|
||||||
|
// In the order the file has them, which is usually the last row first: going back through a
|
||||||
|
// file on the card costs far more than going on (measured: a second for 135 rows).
|
||||||
|
for (int stored = 0; stored < h; stored++) {
|
||||||
|
int y = topDown ? stored : h - 1 - stored;
|
||||||
|
if (rowNeeded && !rowNeeded(y)) continue;
|
||||||
|
uint32_t rowAt = dataAt + rowSize * static_cast<uint32_t>(stored);
|
||||||
|
for (uint32_t done = 0; done < rowBytes; done += bufSize) {
|
||||||
|
size_t want = std::min(bufSize, rowBytes - done);
|
||||||
|
if (read(rowAt + done, in.get(), want) != want) return "The card refused to read it";
|
||||||
|
int first = static_cast<int>(done / bytes), count = static_cast<int>(want / bytes);
|
||||||
|
for (int at = 0; at < count; at += kOut) {
|
||||||
|
int n = std::min(kOut, count - at);
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
const uint8_t* p = bits == 8 ? palette.get() + in[at + i] * 4 : in.get() + static_cast<size_t>(at + i) * bytes;
|
||||||
|
out[i * 3] = p[2]; // stored blue, green, red
|
||||||
|
out[i * 3 + 1] = p[1];
|
||||||
|
out[i * 3 + 2] = p[0];
|
||||||
|
}
|
||||||
|
pixels(first + at, y, n, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
struct GifWork {
|
||||||
|
uint8_t palette[768];
|
||||||
|
uint16_t prefix[4096];
|
||||||
|
uint8_t suffix[4096], stack[4096];
|
||||||
|
};
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::string readGif(const ImageRead& read, uint32_t size, const ImagePixels& pixels) {
|
||||||
|
Stream in(read, size);
|
||||||
|
uint8_t head[13];
|
||||||
|
if (!in.take(head, 13) || imageKindOfBytes(head, 6) != ImageKind::Gif) return "This GIF is damaged";
|
||||||
|
int screenW = static_cast<int>(le16(head + 6)), screenH = static_cast<int>(le16(head + 8));
|
||||||
|
std::unique_ptr<GifWork> work(new (std::nothrow) GifWork);
|
||||||
|
if (!work) return "Not enough memory to show a GIF";
|
||||||
|
std::memset(work->palette, 0, sizeof work->palette);
|
||||||
|
if (head[10] & 0x80 && !in.take(work->palette, 3u << ((head[10] & 7) + 1))) return "This GIF is damaged";
|
||||||
|
int transparent = -1;
|
||||||
|
for (int guard = 0; guard < 100000; guard++) {
|
||||||
|
int kind = in.get();
|
||||||
|
if (kind == 0x21) { // an extension: only the one before a picture matters, for its transparent colour
|
||||||
|
int label = in.get();
|
||||||
|
for (bool first = true;; first = false) {
|
||||||
|
int len = in.get();
|
||||||
|
if (len < 0) return "This GIF is damaged";
|
||||||
|
if (len == 0) break;
|
||||||
|
uint8_t block[255];
|
||||||
|
if (!in.take(block, static_cast<size_t>(len))) return "This GIF is damaged";
|
||||||
|
if (label == 0xF9 && first && len >= 4) transparent = (block[0] & 1) ? block[3] : -1;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (kind != 0x2C) return kind == 0x3B ? "This GIF has no picture" : "This GIF is damaged";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
uint8_t desc[9];
|
||||||
|
if (!in.take(desc, 9)) return "This GIF is damaged";
|
||||||
|
int left = static_cast<int>(le16(desc)), top = static_cast<int>(le16(desc + 2));
|
||||||
|
int fw = static_cast<int>(le16(desc + 4)), fh = static_cast<int>(le16(desc + 6));
|
||||||
|
bool interlaced = desc[8] & 0x40;
|
||||||
|
if (desc[8] & 0x80 && !in.take(work->palette, 3u << ((desc[8] & 7) + 1))) return "This GIF is damaged";
|
||||||
|
int minBits = in.get();
|
||||||
|
if (fw <= 0 || fh <= 0 || minBits < 2 || minBits > 8) return "This GIF is damaged";
|
||||||
|
|
||||||
|
// The pixels come out in the order they are stored; an interlaced picture stores every eighth
|
||||||
|
// row first, then the rows between, in four passes.
|
||||||
|
static const int kStart[4] = {0, 4, 2, 1}, kStep[4] = {8, 8, 4, 2};
|
||||||
|
int px = 0, row = 0, pass = 0, rowsDone = 0;
|
||||||
|
uint8_t run[64 * 3];
|
||||||
|
int runLen = 0, runX = 0;
|
||||||
|
auto flush = [&]() {
|
||||||
|
int y = top + row;
|
||||||
|
if (runLen && y >= 0 && y < screenH) pixels(left + runX, y, runLen, run);
|
||||||
|
runLen = 0;
|
||||||
|
};
|
||||||
|
auto put = [&](uint8_t index) {
|
||||||
|
if (rowsDone >= fh) return;
|
||||||
|
int x = left + px;
|
||||||
|
if (index == transparent || x < 0 || x >= screenW) {
|
||||||
|
flush();
|
||||||
|
} else {
|
||||||
|
if (!runLen) runX = px;
|
||||||
|
std::memcpy(run + runLen * 3, work->palette + index * 3, 3);
|
||||||
|
if (++runLen == 64) flush();
|
||||||
|
}
|
||||||
|
if (++px < fw) return;
|
||||||
|
flush();
|
||||||
|
px = 0;
|
||||||
|
rowsDone++;
|
||||||
|
if (!interlaced) {
|
||||||
|
row++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
row += kStep[pass];
|
||||||
|
while (row >= fh && pass < 3) row = kStart[++pass];
|
||||||
|
};
|
||||||
|
|
||||||
|
const int clear = 1 << minBits, stop = clear + 1;
|
||||||
|
int bits = minBits + 1, next = clear + 2, prev = -1, first = 0;
|
||||||
|
uint32_t hold = 0;
|
||||||
|
int held = 0, blockLeft = 0;
|
||||||
|
bool ended = false;
|
||||||
|
for (int i = 0; i < clear; i++) work->suffix[i] = static_cast<uint8_t>(i);
|
||||||
|
while (rowsDone < fh && !ended) {
|
||||||
|
while (held < bits) {
|
||||||
|
if (!blockLeft) {
|
||||||
|
blockLeft = in.get();
|
||||||
|
if (blockLeft <= 0) {
|
||||||
|
ended = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int c = in.get();
|
||||||
|
if (c < 0) return "This GIF is cut short";
|
||||||
|
blockLeft--;
|
||||||
|
hold |= static_cast<uint32_t>(c) << held;
|
||||||
|
held += 8;
|
||||||
|
}
|
||||||
|
if (ended) break;
|
||||||
|
int code = static_cast<int>(hold & ((1u << bits) - 1));
|
||||||
|
hold >>= bits;
|
||||||
|
held -= bits;
|
||||||
|
if (code == clear) {
|
||||||
|
bits = minBits + 1;
|
||||||
|
next = clear + 2;
|
||||||
|
prev = -1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (code == stop) break;
|
||||||
|
if (prev < 0) {
|
||||||
|
if (code >= clear) return "This GIF is damaged";
|
||||||
|
put(static_cast<uint8_t>(code));
|
||||||
|
first = prev = code;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (code > next) return "This GIF is damaged";
|
||||||
|
int sp = 0, walk = code;
|
||||||
|
if (code == next) { // the string being defined: the one before, and its own first pixel again
|
||||||
|
work->stack[sp++] = static_cast<uint8_t>(first);
|
||||||
|
walk = prev;
|
||||||
|
}
|
||||||
|
while (walk >= clear && sp < 4095) {
|
||||||
|
work->stack[sp++] = work->suffix[walk];
|
||||||
|
walk = work->prefix[walk];
|
||||||
|
}
|
||||||
|
if (walk >= clear) return "This GIF is damaged";
|
||||||
|
first = walk;
|
||||||
|
work->stack[sp++] = static_cast<uint8_t>(walk);
|
||||||
|
if (next < 4096) {
|
||||||
|
work->prefix[next] = static_cast<uint16_t>(prev);
|
||||||
|
work->suffix[next] = static_cast<uint8_t>(first);
|
||||||
|
next++;
|
||||||
|
if (next == (1 << bits) && bits < 12) bits++;
|
||||||
|
}
|
||||||
|
prev = code;
|
||||||
|
while (sp) put(work->stack[--sp]);
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
return rowsDone ? "" : "This GIF is damaged";
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace roro::files
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <functional>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
// Pictures for the Storage App (issue #45, F1 Q233-Q242): what a file is and how big, where each
|
||||||
|
// of its pixels goes on the screen, and the readers for BMP and the first frame of a GIF. PNG is in
|
||||||
|
// png_reader.h; JPEG is decoded on the device by the display library's decoder.
|
||||||
|
// Nothing here holds a picture: every reader hands its pixels on as it gets them.
|
||||||
|
namespace roro::files {
|
||||||
|
|
||||||
|
enum class ImageKind : uint8_t { None, Png, Jpeg, Bmp, Gif };
|
||||||
|
|
||||||
|
// Reads up to `len` bytes at `offset`; returns how many it got.
|
||||||
|
using ImageRead = std::function<size_t(uint32_t offset, uint8_t* into, size_t len)>;
|
||||||
|
// `count` pixels of row `y` from column `x` on, three bytes each: red, green, blue.
|
||||||
|
using ImagePixels = std::function<void(int x, int y, int count, const uint8_t* rgb)>;
|
||||||
|
|
||||||
|
ImageKind imageKindOfName(const std::string& name); // by its extension
|
||||||
|
ImageKind imageKindOfBytes(const uint8_t* head, size_t len); // by its first bytes (8 are enough)
|
||||||
|
const char* imageKindName(ImageKind kind);
|
||||||
|
|
||||||
|
struct ImageInfo {
|
||||||
|
ImageKind kind = ImageKind::None;
|
||||||
|
int width = 0, height = 0;
|
||||||
|
};
|
||||||
|
// "" and `out` filled, or why the file can't be shown.
|
||||||
|
std::string imageInfo(const ImageRead& read, uint32_t size, ImageInfo& out);
|
||||||
|
|
||||||
|
// A PNG the firmware's `screenshot` wrote (png_rgb332.h): its pixels are not compressed and each
|
||||||
|
// is already a colour of the screen. Where the first row's pixels start, or 0 if it isn't one.
|
||||||
|
// Row y's pixels are at that offset + y * (width + 1).
|
||||||
|
uint32_t screenshotPixelsAt(const ImageRead& read, uint32_t size, int width, int height);
|
||||||
|
|
||||||
|
// A colour as the screen has it (RRRGGGBB), dithered by where it lands: the screen has 8 levels of
|
||||||
|
// red and green and 4 of blue, and a photograph bands without it. A colour the screen has exactly
|
||||||
|
// comes out as itself wherever it lands, so a screenshot isn't touched.
|
||||||
|
uint8_t rgb332Dithered(uint8_t r, uint8_t g, uint8_t b, int x, int y);
|
||||||
|
|
||||||
|
// From a picture's pixels to the screen's.
|
||||||
|
struct ImageMap {
|
||||||
|
int viewX = 0, viewY = 0, viewW = 0, viewH = 0; // the part of the screen the picture may use
|
||||||
|
int offX = 0, offY = 0; // the picture's corner in it (negative: scrolled)
|
||||||
|
uint32_t scale = 65536; // screen pixels for one of the picture's, 16.16; never over 1
|
||||||
|
|
||||||
|
// Where the picture's pixel lands. False if it's outside the view, or if another pixel is the
|
||||||
|
// one drawn there (shrunk, each screen pixel takes the first of the picture's that falls on it).
|
||||||
|
bool at(int sx, int sy, int& tx, int& ty) const;
|
||||||
|
bool rowUsed(int sy) const; // does any pixel of this row land?
|
||||||
|
bool below(int sy) const; // this row and every one after it land under the view: nothing more to draw
|
||||||
|
};
|
||||||
|
|
||||||
|
// How a picture is looked at: whole, shrunk to fit if it has to be; or at its own size, a
|
||||||
|
// screenful at a time.
|
||||||
|
class ImageFrame {
|
||||||
|
public:
|
||||||
|
ImageFrame() = default;
|
||||||
|
ImageFrame(int width, int height, int viewX, int viewY, int viewW, int viewH);
|
||||||
|
|
||||||
|
bool bigger() const { return w_ > vw_ || h_ > vh_; } // than the view: there is something to zoom
|
||||||
|
bool actual() const { return actual_; }
|
||||||
|
void toggle();
|
||||||
|
bool pan(int dx, int dy); // half a view a step, at its own size only; false: nothing moved
|
||||||
|
int percent() const; // of its own size, as shown
|
||||||
|
int jpegShrink() const; // 0 to 3: the halvings a JPEG decoder may do first, the picture still at least as big as shown
|
||||||
|
// For pixels counted after `shrink` halvings (a JPEG's), or the picture's own.
|
||||||
|
ImageMap map(int shrink = 0) const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
uint32_t fitScale() const;
|
||||||
|
|
||||||
|
int w_ = 0, h_ = 0, vx_ = 0, vy_ = 0, vw_ = 1, vh_ = 1, panX_ = 0, panY_ = 0;
|
||||||
|
bool actual_ = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// A BMP: 8 bits with a palette, 24 or 32 bits, not compressed. `rowNeeded` lets rows be skipped
|
||||||
|
// without being read. "" or why it can't be shown.
|
||||||
|
std::string readBmp(const ImageRead& read, uint32_t size, const ImagePixels& pixels, const std::function<bool(int y)>& rowNeeded = nullptr);
|
||||||
|
|
||||||
|
// The first picture of a GIF, interlaced or not; transparent pixels are not handed on. It needs
|
||||||
|
// 17 KB while it runs. "" or why it can't be shown.
|
||||||
|
std::string readGif(const ImageRead& read, uint32_t size, const ImagePixels& pixels);
|
||||||
|
|
||||||
|
} // namespace roro::files
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
#include "png_reader.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstring>
|
||||||
|
#include <memory>
|
||||||
|
#include <new>
|
||||||
|
|
||||||
|
namespace roro::files {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
uint32_t be32(const uint8_t* p) { return (static_cast<uint32_t>(p[0]) << 24) | (p[1] << 16) | (p[2] << 8) | p[3]; }
|
||||||
|
|
||||||
|
const char* const kDamaged = "This PNG is damaged";
|
||||||
|
const char* const kCut = "This PNG is cut short";
|
||||||
|
const char* const kNoMemory = "Not enough memory for this PNG";
|
||||||
|
|
||||||
|
// A Huffman code as its lengths say: how many codes of each length, and the symbols in order.
|
||||||
|
struct Huffman {
|
||||||
|
uint16_t count[16];
|
||||||
|
uint16_t symbol[288];
|
||||||
|
|
||||||
|
// False if the lengths don't make a code.
|
||||||
|
bool build(const uint8_t* lengths, int n) {
|
||||||
|
std::memset(count, 0, sizeof count);
|
||||||
|
for (int i = 0; i < n; i++) count[lengths[i]]++;
|
||||||
|
int left = 1;
|
||||||
|
for (int len = 1; len < 16; len++) {
|
||||||
|
left = (left << 1) - count[len];
|
||||||
|
if (left < 0) return false;
|
||||||
|
}
|
||||||
|
uint16_t offs[16];
|
||||||
|
offs[1] = 0;
|
||||||
|
for (int len = 1; len < 15; len++) offs[len + 1] = static_cast<uint16_t>(offs[len] + count[len]);
|
||||||
|
for (int i = 0; i < n; i++)
|
||||||
|
if (lengths[i]) symbol[offs[lengths[i]]++] = static_cast<uint16_t>(i);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Everything one decoding holds, but the window and the two rows: on the heap, in one piece.
|
||||||
|
struct Work {
|
||||||
|
// The file, and the IDAT chunks as one stream of bytes.
|
||||||
|
const ImageRead* read = nullptr;
|
||||||
|
uint32_t size = 0, at = 0, chunkLeft = 0;
|
||||||
|
uint8_t in[256];
|
||||||
|
size_t inHave = 0, inAt = 0;
|
||||||
|
bool inEnd = false;
|
||||||
|
// Bits.
|
||||||
|
uint32_t hold = 0;
|
||||||
|
int held = 0;
|
||||||
|
// The picture.
|
||||||
|
int width = 0, height = 0, depth = 0, type = 0, channels = 0, bpp = 0;
|
||||||
|
uint32_t rowBytes = 0;
|
||||||
|
uint8_t palette[768], alpha[256];
|
||||||
|
bool hasAlpha = false;
|
||||||
|
// The window, the rows, and where the decoding is.
|
||||||
|
std::unique_ptr<uint8_t[]> window, rows;
|
||||||
|
uint32_t windowSize = 0, written = 0;
|
||||||
|
uint8_t *cur = nullptr, *prev = nullptr;
|
||||||
|
int64_t pos = -1; // in the row; -1: its filter byte comes next
|
||||||
|
int filter = 0, y = 0;
|
||||||
|
bool stop = false;
|
||||||
|
const char* problem = nullptr;
|
||||||
|
const ImagePixels* pixels = nullptr;
|
||||||
|
const std::function<bool(int)>* rowNeeded = nullptr;
|
||||||
|
const std::function<bool(int)>* enough = nullptr;
|
||||||
|
Huffman lengths, distances;
|
||||||
|
uint8_t codeLengths[320];
|
||||||
|
|
||||||
|
int byte() {
|
||||||
|
if (inAt >= inHave) {
|
||||||
|
while (!chunkLeft && !inEnd) { // the next IDAT, past this one's checksum
|
||||||
|
uint8_t head[12];
|
||||||
|
if (at + 12 > size || (*read)(at, head, 12) != 12) return inEnd = true, -1;
|
||||||
|
at += 4; // the checksum
|
||||||
|
if (std::memcmp(head + 8, "IDAT", 4) != 0) return inEnd = true, -1;
|
||||||
|
chunkLeft = be32(head + 4);
|
||||||
|
at += 8;
|
||||||
|
}
|
||||||
|
if (inEnd) return -1;
|
||||||
|
size_t want = std::min<size_t>(sizeof in, chunkLeft);
|
||||||
|
inHave = (*read)(at, in, want);
|
||||||
|
inAt = 0;
|
||||||
|
if (inHave != want) return inEnd = true, -1;
|
||||||
|
at += static_cast<uint32_t>(want);
|
||||||
|
chunkLeft -= static_cast<uint32_t>(want);
|
||||||
|
}
|
||||||
|
return in[inAt++];
|
||||||
|
}
|
||||||
|
int bits(int n) { // -1: no more
|
||||||
|
while (held < n) {
|
||||||
|
int b = byte();
|
||||||
|
if (b < 0) return -1;
|
||||||
|
hold |= static_cast<uint32_t>(b) << held;
|
||||||
|
held += 8;
|
||||||
|
}
|
||||||
|
int v = static_cast<int>(hold & ((1u << n) - 1));
|
||||||
|
hold >>= n;
|
||||||
|
held -= n;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
int decode(const Huffman& h) { // -1: no more, or not a code
|
||||||
|
int code = 0, first = 0, index = 0;
|
||||||
|
for (int len = 1; len < 16; len++) {
|
||||||
|
int b = bits(1);
|
||||||
|
if (b < 0) return -1;
|
||||||
|
code |= b;
|
||||||
|
int n = h.count[len];
|
||||||
|
if (code - n < first) return h.symbol[index + (code - first)];
|
||||||
|
index += n;
|
||||||
|
first += n;
|
||||||
|
first <<= 1;
|
||||||
|
code <<= 1;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
void row();
|
||||||
|
// One byte out of the decompression: into the window, and into the row being rebuilt.
|
||||||
|
void out(uint8_t b) {
|
||||||
|
window[written++ & (windowSize - 1)] = b;
|
||||||
|
if (pos < 0) {
|
||||||
|
if (b > 4) {
|
||||||
|
problem = kDamaged;
|
||||||
|
stop = true;
|
||||||
|
}
|
||||||
|
filter = b;
|
||||||
|
pos = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uint32_t i = static_cast<uint32_t>(pos);
|
||||||
|
int a = i >= static_cast<uint32_t>(bpp) ? cur[i - bpp] : 0, up = prev[i], c = i >= static_cast<uint32_t>(bpp) ? prev[i - bpp] : 0, add = 0;
|
||||||
|
switch (filter) {
|
||||||
|
case 1: add = a; break;
|
||||||
|
case 2: add = up; break;
|
||||||
|
case 3: add = (a + up) >> 1; break;
|
||||||
|
case 4: {
|
||||||
|
int p = a + up - c, pa = std::abs(p - a), pb = std::abs(p - up), pc = std::abs(p - c);
|
||||||
|
add = pa <= pb && pa <= pc ? a : pb <= pc ? up : c;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
cur[i] = static_cast<uint8_t>(b + add);
|
||||||
|
if (static_cast<uint32_t>(++pos) < rowBytes) return;
|
||||||
|
if (!rowNeeded || !*rowNeeded || (*rowNeeded)(y)) row();
|
||||||
|
std::swap(cur, prev);
|
||||||
|
pos = -1;
|
||||||
|
y++;
|
||||||
|
if (y >= height || (enough && *enough && (*enough)(y))) stop = true;
|
||||||
|
}
|
||||||
|
bool inflate();
|
||||||
|
};
|
||||||
|
|
||||||
|
// The row as colours: runs of pixels, broken where one is transparent.
|
||||||
|
void Work::row() {
|
||||||
|
uint8_t run[64 * 3];
|
||||||
|
int n = 0, from = 0;
|
||||||
|
auto flush = [&]() {
|
||||||
|
if (n) (*pixels)(from, y, n, run);
|
||||||
|
n = 0;
|
||||||
|
};
|
||||||
|
int top = (1 << depth) - 1;
|
||||||
|
for (int x = 0; x < width; x++) {
|
||||||
|
uint8_t r, g, b, a = 255;
|
||||||
|
auto sample = [&](int k) -> int { // the k-th value of this pixel, as 8 bits; an index stays an index
|
||||||
|
if (depth == 8) return cur[x * channels + k];
|
||||||
|
if (depth == 16) return cur[(x * channels + k) * 2];
|
||||||
|
int bit = x * depth, v = (cur[bit >> 3] >> (8 - depth - (bit & 7))) & top;
|
||||||
|
return type == 3 ? v : v * 255 / top;
|
||||||
|
};
|
||||||
|
switch (type) {
|
||||||
|
case 0: r = g = b = static_cast<uint8_t>(sample(0)); break;
|
||||||
|
case 2: r = static_cast<uint8_t>(sample(0)), g = static_cast<uint8_t>(sample(1)), b = static_cast<uint8_t>(sample(2)); break;
|
||||||
|
case 3: {
|
||||||
|
int i = sample(0);
|
||||||
|
r = palette[i * 3], g = palette[i * 3 + 1], b = palette[i * 3 + 2];
|
||||||
|
if (hasAlpha) a = alpha[i];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 4: r = g = b = static_cast<uint8_t>(sample(0)), a = static_cast<uint8_t>(sample(1)); break;
|
||||||
|
default: r = static_cast<uint8_t>(sample(0)), g = static_cast<uint8_t>(sample(1)), b = static_cast<uint8_t>(sample(2)), a = static_cast<uint8_t>(sample(3)); break;
|
||||||
|
}
|
||||||
|
if (a < 128) {
|
||||||
|
flush();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!n) from = x;
|
||||||
|
run[n * 3] = r, run[n * 3 + 1] = g, run[n * 3 + 2] = b;
|
||||||
|
if (++n == 64) flush();
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deflate (RFC 1951) inside a zlib stream (RFC 1950). False with `problem` set, or true when the
|
||||||
|
// stream ended or enough rows were made.
|
||||||
|
bool Work::inflate() {
|
||||||
|
static const uint16_t kLenBase[29] = {3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258};
|
||||||
|
static const uint8_t kLenExtra[29] = {0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0};
|
||||||
|
static const uint16_t kDistBase[30] = {1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, 1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577};
|
||||||
|
static const uint8_t kDistExtra[30] = {0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13};
|
||||||
|
static const uint8_t kOrder[19] = {16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15};
|
||||||
|
auto fail = [this](const char* what) {
|
||||||
|
if (!problem) problem = what;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
for (bool last = false; !last && !stop;) {
|
||||||
|
int head = bits(3);
|
||||||
|
if (head < 0) return fail(kCut);
|
||||||
|
last = head & 1;
|
||||||
|
int kind = head >> 1;
|
||||||
|
if (kind == 0) { // stored
|
||||||
|
hold = 0;
|
||||||
|
held = 0;
|
||||||
|
int a = byte(), b = byte(), c = byte(), d = byte();
|
||||||
|
if (d < 0) return fail(kCut);
|
||||||
|
int len = a | (b << 8);
|
||||||
|
if (len != ((c | (d << 8)) ^ 0xFFFF)) return fail(kDamaged);
|
||||||
|
for (int i = 0; i < len && !stop; i++) {
|
||||||
|
int v = byte();
|
||||||
|
if (v < 0) return fail(kCut);
|
||||||
|
out(static_cast<uint8_t>(v));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (kind == 3) return fail(kDamaged);
|
||||||
|
if (kind == 1) { // the code every decoder knows
|
||||||
|
for (int i = 0; i < 288; i++) codeLengths[i] = i < 144 ? 8 : i < 256 ? 9 : i < 280 ? 7 : 8;
|
||||||
|
lengths.build(codeLengths, 288);
|
||||||
|
for (int i = 0; i < 30; i++) codeLengths[i] = 5;
|
||||||
|
distances.build(codeLengths, 30);
|
||||||
|
} else { // a code of the block's own, itself sent coded
|
||||||
|
int nlen = bits(5), ndist = bits(5), ncode = bits(4);
|
||||||
|
if (ncode < 0) return fail(kCut);
|
||||||
|
nlen += 257, ndist += 1, ncode += 4;
|
||||||
|
if (nlen > 286 || ndist > 30) return fail(kDamaged);
|
||||||
|
uint8_t first[19] = {0};
|
||||||
|
for (int i = 0; i < ncode; i++) {
|
||||||
|
int v = bits(3);
|
||||||
|
if (v < 0) return fail(kCut);
|
||||||
|
first[kOrder[i]] = static_cast<uint8_t>(v);
|
||||||
|
}
|
||||||
|
if (!lengths.build(first, 19)) return fail(kDamaged);
|
||||||
|
for (int i = 0; i < nlen + ndist;) {
|
||||||
|
int sym = decode(lengths);
|
||||||
|
if (sym < 0) return fail(kDamaged);
|
||||||
|
if (sym < 16) {
|
||||||
|
codeLengths[i++] = static_cast<uint8_t>(sym);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
int repeat, value = 0;
|
||||||
|
if (sym == 16) {
|
||||||
|
if (!i) return fail(kDamaged);
|
||||||
|
value = codeLengths[i - 1];
|
||||||
|
repeat = 3 + bits(2);
|
||||||
|
} else if (sym == 17) {
|
||||||
|
repeat = 3 + bits(3);
|
||||||
|
} else {
|
||||||
|
repeat = 11 + bits(7);
|
||||||
|
}
|
||||||
|
if (i + repeat > nlen + ndist) return fail(kDamaged);
|
||||||
|
while (repeat--) codeLengths[i++] = static_cast<uint8_t>(value);
|
||||||
|
}
|
||||||
|
uint8_t dist[30];
|
||||||
|
std::memcpy(dist, codeLengths + nlen, static_cast<size_t>(ndist));
|
||||||
|
if (!lengths.build(codeLengths, nlen) || !distances.build(dist, ndist)) return fail(kDamaged);
|
||||||
|
}
|
||||||
|
while (!stop) {
|
||||||
|
int sym = decode(lengths);
|
||||||
|
if (sym < 0) return fail(inEnd ? kCut : kDamaged);
|
||||||
|
if (sym < 256) {
|
||||||
|
out(static_cast<uint8_t>(sym));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (sym == 256) break;
|
||||||
|
sym -= 257;
|
||||||
|
if (sym >= 29) return fail(kDamaged);
|
||||||
|
int extra = bits(kLenExtra[sym]);
|
||||||
|
int dsym = decode(distances);
|
||||||
|
if (extra < 0 || dsym < 0 || dsym >= 30) return fail(inEnd ? kCut : kDamaged);
|
||||||
|
int dextra = bits(kDistExtra[dsym]);
|
||||||
|
if (dextra < 0) return fail(kCut);
|
||||||
|
uint32_t len = static_cast<uint32_t>(kLenBase[sym] + extra), dist = static_cast<uint32_t>(kDistBase[dsym] + dextra);
|
||||||
|
if (dist > written || dist > windowSize) return fail(kDamaged);
|
||||||
|
for (uint32_t i = 0; i < len && !stop; i++) out(window[(written - dist) & (windowSize - 1)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::string readPng(const ImageRead& read, uint32_t size, const ImagePixels& pixels, const std::function<bool(int y)>& rowNeeded,
|
||||||
|
const std::function<bool(int y)>& enough) {
|
||||||
|
static const uint8_t kSignature[] = {0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n'};
|
||||||
|
uint8_t head[33];
|
||||||
|
if (read(0, head, sizeof head) != sizeof head || std::memcmp(head, kSignature, 8) != 0 || std::memcmp(head + 12, "IHDR", 4) != 0) return kDamaged;
|
||||||
|
std::unique_ptr<Work> w(new (std::nothrow) Work);
|
||||||
|
if (!w) return kNoMemory;
|
||||||
|
w->read = &read;
|
||||||
|
w->size = size;
|
||||||
|
w->pixels = &pixels;
|
||||||
|
w->rowNeeded = &rowNeeded;
|
||||||
|
w->enough = &enough;
|
||||||
|
uint32_t width = be32(head + 16), height = be32(head + 20);
|
||||||
|
w->depth = head[24];
|
||||||
|
w->type = head[25];
|
||||||
|
if (!width || !height || width > 16384 || height > 16384 || head[26] || head[27]) return kDamaged;
|
||||||
|
if (head[28]) return "An interlaced PNG can't be shown";
|
||||||
|
w->width = static_cast<int>(width);
|
||||||
|
w->height = static_cast<int>(height);
|
||||||
|
static const int8_t kChannels[7] = {1, 0, 3, 1, 2, 0, 4};
|
||||||
|
int depth = w->depth, type = w->type;
|
||||||
|
bool depthOk = depth == 8 || (depth == 16 && type != 3) || ((depth == 1 || depth == 2 || depth == 4) && (type == 0 || type == 3));
|
||||||
|
if (type > 6 || !kChannels[type] || !depthOk) return "This kind of PNG can't be shown";
|
||||||
|
w->channels = kChannels[type];
|
||||||
|
w->bpp = std::max(1, w->channels * depth / 8);
|
||||||
|
w->rowBytes = (width * static_cast<uint32_t>(w->channels * depth) + 7) / 8;
|
||||||
|
std::memset(w->palette, 0, sizeof w->palette);
|
||||||
|
std::memset(w->alpha, 255, sizeof w->alpha);
|
||||||
|
|
||||||
|
// The chunks before the picture: the palette and its transparency.
|
||||||
|
uint32_t at = 33;
|
||||||
|
for (int guard = 0; guard < 1000; guard++) {
|
||||||
|
uint8_t c[8];
|
||||||
|
if (at + 8 > size || read(at, c, 8) != 8) return kCut;
|
||||||
|
uint32_t len = be32(c);
|
||||||
|
if (std::memcmp(c + 4, "IDAT", 4) == 0) break;
|
||||||
|
if (std::memcmp(c + 4, "IEND", 4) == 0 || len > size) return kDamaged;
|
||||||
|
if (std::memcmp(c + 4, "PLTE", 4) == 0 && read(at + 8, w->palette, std::min<size_t>(len, 768)) != std::min<size_t>(len, 768)) return kCut;
|
||||||
|
if (std::memcmp(c + 4, "tRNS", 4) == 0 && type == 3) {
|
||||||
|
if (read(at + 8, w->alpha, std::min<size_t>(len, 256)) != std::min<size_t>(len, 256)) return kCut;
|
||||||
|
w->hasAlpha = true;
|
||||||
|
}
|
||||||
|
at += 12 + len;
|
||||||
|
}
|
||||||
|
w->at = at - 4; // as if a chunk's checksum had just been reached: byte() steps over it to the IDAT
|
||||||
|
|
||||||
|
// The zlib header says how far back the data refers: the window is that big and no bigger.
|
||||||
|
int cmf = w->byte(), flg = w->byte();
|
||||||
|
if (flg < 0) return kCut;
|
||||||
|
if ((cmf & 0x0F) != 8 || (cmf >> 4) > 7 || ((cmf << 8) | flg) % 31 || (flg & 0x20)) return kDamaged;
|
||||||
|
w->windowSize = 1u << ((cmf >> 4) + 8);
|
||||||
|
w->window.reset(new (std::nothrow) uint8_t[w->windowSize]);
|
||||||
|
w->rows.reset(new (std::nothrow) uint8_t[static_cast<size_t>(w->rowBytes) * 2]());
|
||||||
|
if (!w->window || !w->rows) return kNoMemory;
|
||||||
|
w->cur = w->rows.get();
|
||||||
|
w->prev = w->rows.get() + w->rowBytes;
|
||||||
|
if (enough && enough(0)) return "";
|
||||||
|
if (!w->inflate()) return w->problem ? w->problem : kDamaged;
|
||||||
|
if (w->problem) return w->problem;
|
||||||
|
return w->stop ? "" : kCut; // the data ended before the last row
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace roro::files
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "image_file.h"
|
||||||
|
|
||||||
|
namespace roro::files {
|
||||||
|
|
||||||
|
// A PNG, decoded a row at a time (issue #45): every colour type and bit depth, not interlaced.
|
||||||
|
// Pixels that are mostly transparent are not handed on. `rowNeeded` lets rows be left out (they
|
||||||
|
// are still decoded: a row is stored as its difference from the one before); `enough` says that
|
||||||
|
// from this row on nothing is wanted, and the decoding stops there.
|
||||||
|
//
|
||||||
|
// Memory while it runs: the window the file's compression refers back into (what its header asks
|
||||||
|
// for, 32 KB at most), two rows of the picture, and about 3 KB. The display library's decoder
|
||||||
|
// wanted 44 KB in one block, which this device often doesn't have.
|
||||||
|
//
|
||||||
|
// "" or why it can't be shown.
|
||||||
|
std::string readPng(const ImageRead& read, uint32_t size, const ImagePixels& pixels, const std::function<bool(int y)>& rowNeeded = nullptr,
|
||||||
|
const std::function<bool(int y)>& enough = nullptr);
|
||||||
|
|
||||||
|
} // namespace roro::files
|
||||||
@@ -106,6 +106,13 @@ void KeyMapper::onChar(char c, const RawKeys& keys, std::vector<KeyEvent>& out)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case 'p':
|
||||||
|
case 'P':
|
||||||
|
if (keys.fn) { // Fn+p: a screenshot, while typing too
|
||||||
|
out.push_back(KeyEvent::of(Key::Screenshot));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
break;
|
||||||
case '?':
|
case '?':
|
||||||
if (!textEntry_ && !keys.fn) { // ? alone, when it wouldn't be typed
|
if (!textEntry_ && !keys.fn) { // ? alone, when it wouldn't be typed
|
||||||
out.push_back(KeyEvent::of(Key::Help));
|
out.push_back(KeyEvent::of(Key::Help));
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ gnss quiet on|off pause the GNSS receiver while the LoRa radio listens (it cos
|
|||||||
gnss status | gnss restart | gnss track start|stop | gnss nmea on|off | gnss send <sentence without $ and checksum>
|
gnss status | gnss restart | gnss track start|stop | gnss nmea on|off | gnss send <sentence without $ and checksum>
|
||||||
crash the last crash: firmware, reason, task, backtrace
|
crash the last crash: firmware, reason, task, backtrace
|
||||||
coredump erase forget the core dump in flash
|
coredump erase forget the core dump in flash
|
||||||
key <name|char> press a key: up down left right select back home del tab space help, or one character; ctrl- alt- shift- before it (key ctrl-down)
|
key <name|char> press a key: up down left right select back home del tab space help shot, or one character; ctrl- alt- shift- before it (key ctrl-down)
|
||||||
wifi status | wifi add <ssid><TAB><password>
|
wifi status | wifi add <ssid><TAB><password>
|
||||||
wifi ip <ssid> dhcp | wifi ip <ssid> <address>/<prefix> [gateway] a Saved Network's IP setting
|
wifi ip <ssid> dhcp | wifi ip <ssid> <address>/<prefix> [gateway] a Saved Network's IP setting
|
||||||
wifi dns <a> [b] | wifi dns always on|off | wifi ntp <a> [b] DNS and NTP servers
|
wifi dns <a> [b] | wifi dns always on|off | wifi ntp <a> [b] DNS and NTP servers
|
||||||
@@ -62,7 +62,7 @@ In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few r
|
|||||||
| Command | Effect |
|
| Command | Effect |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `burst` | Publishes 5 Notifications at once |
|
| `burst` | Publishes 5 Notifications at once |
|
||||||
| `key up\|down\|left\|right\|select\|back\|home\|del\|tab\|space\|help`, or `key <char>` | Injects a key press (`help` is Fn+h: the keys of the screen that is showing). `ctrl-`, `alt-` and `shift-` before it hold that key: `key ctrl-down`, `key alt-up`, `key ctrl-b` |
|
| `key up\|down\|left\|right\|select\|back\|home\|del\|tab\|space\|help\|shot`, or `key <char>` | Injects a key press (`help` is Fn+h: the keys of the screen that is showing; `shot` is Fn+p: a screenshot). `ctrl-`, `alt-` and `shift-` before it hold that key: `key ctrl-down`, `key alt-up`, `key ctrl-b` |
|
||||||
| `sound on` / `sound off` | Toggles the Sound setting (beep + LED) |
|
| `sound on` / `sound off` | Toggles the Sound setting (beep + LED) |
|
||||||
| `short` / `normal` | Screen timeouts 5 s / 10 s, or 30 s / 60 s |
|
| `short` / `normal` | Screen timeouts 5 s / 10 s, or 30 s / 60 s |
|
||||||
| `wifi add <ssid><TAB><password>` | Adds a Saved Network (so credentials stay out of the repo) |
|
| `wifi add <ssid><TAB><password>` | Adds a Saved Network (so credentials stay out of the repo) |
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ Everything the keyboard can do, a command can do, and everything on the screen c
|
|||||||
## Keys
|
## Keys
|
||||||
|
|
||||||
```
|
```
|
||||||
key up|down|left|right|select|back|home|del|tab|space|help
|
key up|down|left|right|select|back|home|del|tab|space|help|shot
|
||||||
key a # any single character: it is typed
|
key a # any single character: it is typed
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -224,3 +224,77 @@ Test notes were copied to `/notes` and removed afterwards; the note that was alr
|
|||||||
**Not checked:** the power button's path (side file only), the screen turning off, a card pulled while editing, and memory with IRC connected, which wasn't connected for these checks: the editor's own use hasn't changed, and it still refuses to open without a free block of 24 KB. The real keyboard's Ctrl with Fn and the arrows. A file of tens of megabytes. Renaming or deleting a note from the Storage App leaves its side file behind.
|
**Not checked:** the power button's path (side file only), the screen turning off, a card pulled while editing, and memory with IRC connected, which wasn't connected for these checks: the editor's own use hasn't changed, and it still refuses to open without a free block of 24 KB. The real keyboard's Ctrl with Fn and the arrows. A file of tens of megabytes. Renaming or deleting a note from the Storage App leaves its side file behind.
|
||||||
|
|
||||||
**Measured against what was said:** the first build rewrote 1.2 MB in 3.5 to 4.5 s, with 2 KB blocks. With 4 KB blocks it is 2.6 s, about 450 KB a second, which is what the card gives a plain copy.
|
**Measured against what was said:** the first build rewrote 1.2 MB in 3.5 to 4.5 s, with 2 KB blocks. With 4 KB blocks it is 2.6 s, about 450 KB a second, which is what the card gives a plain copy.
|
||||||
|
|
||||||
|
## Pictures in the Storage App (issue #45)
|
||||||
|
|
||||||
|
Q139 left images out: the firmware wrote none. Since the Shell's `screenshot` it does.
|
||||||
|
|
||||||
|
### Decisions (design round 2026-10-07)
|
||||||
|
|
||||||
|
| # | Decision |
|
||||||
|
|---|---|
|
||||||
|
| Q233 | **PNG, JPEG, BMP and GIF.** A GIF shows its first picture; it doesn't move. |
|
||||||
|
| Q234 | *Revised while building.* **Our own PNG decoder**, a row at a time, with the window the file's compression asks for: 32 KB at most. The plan was the display library's, which takes 44 KB in one block: after one picture the largest free block was 43 to 47 KB, and every second PNG was refused. **The firmware's own screenshots** are read with no decoding at all: they are stored uncompressed, each byte already a colour of the screen. |
|
||||||
|
| Q235 | **The picture is decoded once, straight into the screen's buffer, and left there.** No copy in memory (it would be up to 30 KB). `App::retainsContent()` tells the screen not to clear the App's part; `contentLost()` tells the App that it was cleared after all, or that a Toast or the help panel drawn over it has gone: then it is decoded again. |
|
||||||
|
| Q236 | **Shrunk to fit; Enter shows it at its own size**, the arrows then moving half a screen. A picture smaller than the screen sits in the middle at its size. |
|
||||||
|
| Q237 | **Ordered dithering** to the screen's 256 colours (a 4 x 4 pattern). A colour the screen has exactly comes out as itself wherever it lands, so a screenshot isn't touched. |
|
||||||
|
| Q238 | Shrinking takes, for each pixel of the screen, the first of the picture's that falls on it. No averaging: there is nowhere to keep the sums. Thin lines break up; a JPEG looks better, its decoder halving it up to three times first. |
|
||||||
|
| Q239 | **What can't be shown opens as hex, with the reason:** a progressive JPEG, an interlaced PNG, a BMP that is compressed or has 16 bits. The rotation a camera stores in the file is ignored. |
|
||||||
|
| Q240 | *Revised while building.* **Decoding runs on the storage task while the main loop goes on.** The picture appears as it comes, and anything that needs the screen back stops the decoding first. The plan was to wait for it, behind a "Decoding..." line: 12 megapixels took longer than the watchdog allows the main loop to stand still, and the device restarted. |
|
||||||
|
| Q241 | The Storage App: Enter on `.png`, `.jpg`, `.jpeg`, `.bmp`, `.gif`, or on a file with no known extension whose first bytes say what it is. Tab gives the hex. `i`, and opening, show the size in pixels on the last line for three seconds. |
|
||||||
|
| Q242 | Not in this one: animation, opening a picture from the Gemini App, a slideshow. |
|
||||||
|
|
||||||
|
### As built
|
||||||
|
|
||||||
|
- **`lib/files/src/image_file.h`** (host-tested): what a file is and how big, where each pixel lands (`ImageFrame`, `ImageMap`), the dithering, and readers for BMP and GIF that hand their pixels on as they get them. **`png_reader.h`**: the PNG decoder, with its own inflate: every colour type and bit depth, palettes with transparency. Transparent pixels are left as the background.
|
||||||
|
- **`ImagePane`** (`src/apps/image_pane`) is the view. One decoding is a `Job` shared with the storage task; `cancel()` flags it and waits behind it in the task's queue, which is how the screen is known to be free again.
|
||||||
|
- **JPEG is the one decoder that isn't ours:** the display library's TJpgDec, with its 3.9 KB pool. It shrinks by 2, 4 or 8 while decoding, which is why a photograph is possible at all.
|
||||||
|
- **A decoder stops early** once the rest of the file is below the screen (a picture at its own size), and a BMP's rows that aren't shown aren't read.
|
||||||
|
- **The note** on the last line is written over the picture; the strip under it (2.6 KB) is kept and put back, so showing it costs no decoding.
|
||||||
|
- **A BMP is read in the order its rows are stored**, last row first: reading it top to bottom meant going back through the file for every row, a second for 135 rows.
|
||||||
|
- **Cost:** 21 KB of flash. Nothing while no picture is shown.
|
||||||
|
|
||||||
|
### Measured on the device
|
||||||
|
|
||||||
|
| Picture | Fitted | Its own size |
|
||||||
|
|---|---|---|
|
||||||
|
| A screenshot of ours, 240 x 135 | 80 ms | 78 ms |
|
||||||
|
| PNG, 800 x 600 | 855 ms | 575 ms |
|
||||||
|
| PNG with transparency, 800 x 600 | 1,098 ms | |
|
||||||
|
| JPEG, 800 x 600 | 305 ms | |
|
||||||
|
| JPEG, 4000 x 3000 (2.6 MB) | 6.9 s | 7.7 s (the middle of it) |
|
||||||
|
| GIF, 800 x 600 | 642 ms | |
|
||||||
|
| BMP, 800 x 600 (1.4 MB) | 991 ms | |
|
||||||
|
| BMP, 240 x 135 | 124 ms | |
|
||||||
|
|
||||||
|
Free memory fell to 48.8 KB at the lowest while a PNG was decoded, from 104 KB. The storage task's stack: 3.2 KB never used, of 6.
|
||||||
|
|
||||||
|
### Host tests (20, `test/test_image_file` and `test/test_png_reader`)
|
||||||
|
|
||||||
|
The pictures in them were made with Pillow, so the readers are checked against an encoder that isn't ours: GIFs plain, interlaced, transparent and long enough for the codes to reach 12 bits; BMPs of 8 and 24 bits; PNGs of every kind (colour, with alpha, palette of 8 and 4 bits with a transparent entry, greys of 1, 8 and 16 bits, grey with alpha, not compressed, and one that refers 21,600 bytes back). Every reader is also fed its file with a byte changed, at every few bytes: an answer each time, and no pixel outside the picture.
|
||||||
|
|
||||||
|
### Checks on the device (2026-10-07, driven over the Debug Console)
|
||||||
|
|
||||||
|
Test pictures were copied to a scratch folder and removed afterwards, with the test screenshot.
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|---|---|
|
||||||
|
| Colour bars as PNG, JPEG, BMP and GIF, 240 x 135 and 800 x 600 | The same picture each time, the colours in the right order |
|
||||||
|
| A PNG with a transparent square | The square is the background |
|
||||||
|
| A screenshot taken in the Shell | Shown; at its own size it is the screen, pixel for pixel |
|
||||||
|
| A progressive JPEG | Hex, with "A progressive JPEG can't be shown" |
|
||||||
|
| An animated GIF | Its first picture |
|
||||||
|
| 12 megapixels | Arrives from the top down in 6.9 s; the size is noted when it is whole |
|
||||||
|
| Enter, then the arrows | Its own size from the middle, then half a screen at a time |
|
||||||
|
| Back in the middle of a decoding | The folder's listing at once |
|
||||||
|
| Tab to hex and back, three times; the help panel, then closed | The picture again each time |
|
||||||
|
|
||||||
|
**Not checked:** a photograph from a real camera (the test JPEGs were made by Pillow); a Toast over a picture; a PNG with IRC connected, when there may not be the memory; the card pulled while decoding; the real keyboard.
|
||||||
|
|
||||||
|
### What went wrong while building it
|
||||||
|
|
||||||
|
**The watchdog.** The first version waited for the decoder. The main loop is watched: five seconds without a pass and the device restarts, which is what it did on the 12-megapixel test. The crash report named the decoder's line. The decoding moved to the background, which also made the picture appear as it comes.
|
||||||
|
|
||||||
|
**A decoder that worked once.** The library's PNG decoder showed the first picture and refused the next five: "no memory". It wants 44 KB in one piece, and after some use the largest piece is 43 to 47 KB. Writing a decoder that needs 32 KB was less work than it sounds, and unlike the library's it has tests.
|
||||||
|
|
||||||
|
**Two sentences still said "up to 16 KB"** about editing, in the README and the Storage guide, after issue #47 lifted that. Corrected here.
|
||||||
|
|||||||
@@ -52,3 +52,18 @@ The lists first lived in each App's `help()`, as code. They are now **data, in o
|
|||||||
Three rows lost their second wording on the way, since a table is constant: GNSS's `Tab` and `r`, and the Scanner's `c`, now say both things they do ("record a Track, or stop it") instead of the one that applies. The guide pages keep their written tables too, where they say more than a key list can; those can still drift, and the generated ones under them are the reference.
|
Three rows lost their second wording on the way, since a table is constant: GNSS's `Tab` and `r`, and the Scanner's `c`, now say both things they do ("record a Track, or stop it") instead of the one that applies. The guide pages keep their written tables too, where they say more than a key list can; those can still drift, and the generated ones under them are the reference.
|
||||||
|
|
||||||
**Not checked:** the real Fn+h and `?` on the keyboard (the mapper is host-tested; the device was driven with `key help`); the Setup screens, which only a device that was never set up shows, so their new text has not been seen on a screen; and the states that need something to happen first (a dialog, a copy in progress, a Gemini prompt, a packet's details): their lists were read against the key handling, not looked at.
|
**Not checked:** the real Fn+h and `?` on the keyboard (the mapper is host-tested; the device was driven with `key help`); the Setup screens, which only a device that was never set up shows, so their new text has not been seen on a screen; and the states that need something to happen first (a dialog, a copy in progress, a Gemini prompt, a packet's details): their lists were read against the key handling, not looked at.
|
||||||
|
|
||||||
|
## The screenshot key (issue #83)
|
||||||
|
|
||||||
|
A screenshot could only be taken by typing `screenshot` in the Shell, where "now" is a picture of the Shell.
|
||||||
|
|
||||||
|
- **Fn+p, on every screen**, text fields included, saves the screen as it is (a dialog, the help panel or a Toast if one is showing) as a PNG in `/screenshots`, the way the Shell's command does. A Toast says so once the file is written, so it is never in the picture.
|
||||||
|
- **It never reaches an App.** `Key::Screenshot` comes out of the key mapper and is handled before the App manager, so the help panel stays open and a dialog keeps its selection.
|
||||||
|
- **Not on Settings > Debug Console:** that page shows the token, and a picture of it is a copy of the token in a file. `App::showsSecret()` says so, and the key answers with a Toast instead.
|
||||||
|
- **No card:** a Toast says so.
|
||||||
|
- No setting to switch it off: Fn with a letter isn't pressed by accident.
|
||||||
|
- It is in the "Everywhere" group of the help panel, and so in the website's key tables. `key shot` presses it over the consoles.
|
||||||
|
|
||||||
|
**Checked on the device** (2026-10-07, with `key shot`): in the Launcher, a 33,383-byte PNG appears in `/screenshots` and is the Launcher; with the help panel open, the picture is of the panel (which now lists Fn p) and the panel stays open; on Settings > Debug Console, no file is written; back on the Settings list, one is. The test pictures were removed.
|
||||||
|
|
||||||
|
**Not checked:** the real Fn+p on the keyboard (the mapper is host-tested); the two Toasts that refuse, which weren't looked at (one of them is on the page that mustn't be photographed); a device with no card.
|
||||||
|
|||||||
@@ -176,3 +176,17 @@ Q178 left publishing to the maintainer: a merge, then a command typed on the web
|
|||||||
| No secrets at all; only one of the four | Does nothing and says so; fails and says which are needed |
|
| No secrets at all; only one of the four | Does nothing and says so; fails and says which are needed |
|
||||||
|
|
||||||
**Not checked:** the real web server and the runner, which wait for the key to be installed: whether the runner reaches the server's SSH port is the first thing the first run will tell. Port forwarding, which `restrict` switches off, was not tried. `from=` was not tried either.
|
**Not checked:** the real web server and the runner, which wait for the key to be installed: whether the runner reaches the server's SSH port is the first thing the first run will tell. Port forwarding, which `restrict` switches off, was not tried. `from=` was not tried either.
|
||||||
|
|
||||||
|
## Search (issue #60)
|
||||||
|
|
||||||
|
A search over the documentation: the user guide, the how-tos, the questions and answers, and the developer docs. Not the devlog.
|
||||||
|
|
||||||
|
- **The index is the search page itself** (`/search/`, `templates/search.html`): one list item for each page and for each `##` heading of it, with that part's text, written by Zola from the pages' own content when the site is built. Nothing is fetched and nothing typed leaves the browser, so the Content-Security-Policy needs nothing new, and the web server still only runs `zola build`.
|
||||||
|
- **Without JavaScript** the page is a list of every page and heading of the documentation, each a link.
|
||||||
|
- **With it**, `js/search.js` filters and ranks the items as you type: every word has to be in the part; a word in a heading counts for most, the words side by side for more than scattered, and the user guide, the how-tos and the FAQ come before the developer docs, the milestones last. A result links to its heading, with the text around the match.
|
||||||
|
- **The content pages get no script for it:** the navigation has a link, and the index pages of the guide, the how-tos and the developer docs have a box that is a plain form to `/search/?q=`.
|
||||||
|
- **Size:** about 245 items, about 310 KB of HTML, under 100 KB compressed, loaded only by who searches.
|
||||||
|
|
||||||
|
**Checked** in Chromium with the production Content-Security-Policy on every response, no violation: "probation" (the guide's "Probation and Rollback" first), "safe mode", "rm -r", "how big can a note" (the FAQ's question first), a word that isn't there; typing, following a result to its heading, the box on the guide's index, 390 px wide with no sideways scroll, and JavaScript off. `check_site.py` follows every link of the page, so an index entry can't point at a heading that doesn't exist.
|
||||||
|
|
||||||
|
**Not checked:** other browsers, and a screen reader.
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ The Cardputer's keyboard has no arrow keys and no Escape, so the firmware gives
|
|||||||
| <kbd>Fn</kbd> + <kbd>;</kbd> <kbd>.</kbd> <kbd>,</kbd> <kbd>/</kbd> | The arrows: up, down, left, right |
|
| <kbd>Fn</kbd> + <kbd>;</kbd> <kbd>.</kbd> <kbd>,</kbd> <kbd>/</kbd> | The arrows: up, down, left, right |
|
||||||
| <kbd>;</kbd> <kbd>.</kbd> <kbd>,</kbd> <kbd>/</kbd> alone | The same arrows, as long as you are **not** typing text |
|
| <kbd>;</kbd> <kbd>.</kbd> <kbd>,</kbd> <kbd>/</kbd> alone | The same arrows, as long as you are **not** typing text |
|
||||||
| <kbd>Fn</kbd> + <kbd>h</kbd>, or <kbd>?</kbd> when not typing | **Help:** the keys of the screen you are on |
|
| <kbd>Fn</kbd> + <kbd>h</kbd>, or <kbd>?</kbd> when not typing | **Help:** the keys of the screen you are on |
|
||||||
|
| <kbd>Fn</kbd> + <kbd>p</kbd> | **A screenshot:** the screen as it is, saved as a picture in `/screenshots` on the SD card |
|
||||||
| <kbd>Tab</kbd> | Switches view in an App that has more than one |
|
| <kbd>Tab</kbd> | Switches view in an App that has more than one |
|
||||||
| <kbd>Del</kbd> | Deletes backwards when you type |
|
| <kbd>Del</kbd> | Deletes backwards when you type |
|
||||||
| <kbd>opt</kbd> then an accent, then a letter | Types an accented letter: <kbd>opt</kbd> <kbd>'</kbd> <kbd>e</kbd> gives é |
|
| <kbd>opt</kbd> then an accent, then a letter | Types an accented letter: <kbd>opt</kbd> <kbd>'</kbd> <kbd>e</kbd> gives é |
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ screenshot the screen, now
|
|||||||
screenshot 5 the screen in 5 seconds: time to go to another App
|
screenshot 5 the screen in 5 seconds: time to go to another App
|
||||||
```
|
```
|
||||||
|
|
||||||
The picture is saved as a PNG in `/screenshots` on the SD card, named by date and time, and a Toast says so once it is written (so the Toast is never in the picture). From the Shell, "now" is always a picture of the Shell: use the pause to get to the screen you want. The [Storage App](/guide/storage/) shows the files; to look at them, take the card to a computer.
|
The picture is saved as a PNG in `/screenshots` on the SD card, named by date and time, and a Toast says so once it is written (so the Toast is never in the picture). From the Shell, "now" is always a picture of the Shell: use the pause to get to the screen you want, or, simpler, press <kbd>Fn</kbd> + <kbd>p</kbd> on that screen: it takes the same picture from anywhere. The [Storage App](/guide/storage/) lists the files and [shows them](/guide/storage/#pictures).
|
||||||
|
|
||||||
## What it costs
|
## What it costs
|
||||||
|
|
||||||
|
|||||||
@@ -34,12 +34,23 @@ The App says why when it refuses.
|
|||||||
|
|
||||||
<kbd>Enter</kbd> on a file opens it by its type, and <kbd>Tab</kbd> switches the same file to a hex dump or to text:
|
<kbd>Enter</kbd> on a file opens it by its type, and <kbd>Tab</kbd> switches the same file to a hex dump or to text:
|
||||||
|
|
||||||
- **Text** (`.txt`, `.log`, `.gmi`, `.csv`, and anything that looks like text): only a screenful is read from the card, so a file of any size opens at once, and logs open at the end. Up and down move a line, left and right a page, <kbd>t</kbd> and <kbd>b</kbd> go to the top and the end, and <kbd>e</kbd> edits it (up to 16 KB, as in [Notes](/guide/notes/)).
|
- **Text** (`.txt`, `.log`, `.gmi`, `.csv`, and anything that looks like text): only a screenful is read from the card, so a file of any size opens at once, and logs open at the end. Up and down move a line, left and right a page, <kbd>t</kbd> and <kbd>b</kbd> go to the top and the end, and <kbd>e</kbd> edits it, whatever its size (as in [Notes](/guide/notes/)).
|
||||||
|
- **Pictures** (`.png`, `.jpg`, `.bmp`, `.gif`): see [Pictures](#pictures) below.
|
||||||
- **Captures** (`.pcap`): the packets as the [LoRa Scanner](/guide/lora-scanner/) lists them; <kbd>Enter</kbd> shows one with its Meshtastic header and bytes.
|
- **Captures** (`.pcap`): the packets as the [LoRa Scanner](/guide/lora-scanner/) lists them; <kbd>Enter</kbd> shows one with its Meshtastic header and bytes.
|
||||||
- **Tracks** (`.gpx`): the number of points, the start, the duration and the distance.
|
- **Tracks** (`.gpx`): the number of points, the start, the duration and the distance.
|
||||||
- **Update files** (`.ota`): the version, and whether the file would install: it is checked as an install checks it, signature and contents, without writing anything. <kbd>Enter</kbd> then installs it (see [Updates](/guide/updates/)).
|
- **Update files** (`.ota`): the version, and whether the file would install: it is checked as an install checks it, signature and contents, without writing anything. <kbd>Enter</kbd> then installs it (see [Updates](/guide/updates/)).
|
||||||
- **Anything else:** a hex dump.
|
- **Anything else:** a hex dump.
|
||||||
|
|
||||||
|
## Pictures
|
||||||
|
|
||||||
|
<kbd>Enter</kbd> on a PNG, a JPEG, a BMP or a GIF shows it. A picture bigger than the screen is shrunk to fit; <kbd>Enter</kbd> again shows it **at its own size**, and the arrow keys then move around it, half a screen at a time. <kbd>i</kbd> gives its size in pixels, and <kbd>Tab</kbd> the file as hex.
|
||||||
|
|
||||||
|
- **The screen has 256 colours.** A photograph is dithered to them; a drawing or a screenshot usually has colours the screen shows exactly. The screenshots the [Shell](/guide/shell/) saves are shown pixel for pixel at their own size.
|
||||||
|
- **A big photograph takes time**, because every pixel of the file goes through the decoder, shown or not: about 7 seconds for 12 megapixels. The picture appears as it is decoded, and the keys keep working: Back leaves at once.
|
||||||
|
- **A GIF shows its first picture only.** It doesn't move.
|
||||||
|
- **What can't be shown** opens as hex, with the reason: a progressive JPEG, an interlaced PNG, a BMP that is compressed or has 16 bits a pixel.
|
||||||
|
- **A PNG needs 32 KB of memory in one piece** while it is decoded, and a few more (a JPEG needs 4 KB, a GIF 17 KB). With IRC connected there may not be that much: the viewer says so. The firmware's own screenshots need none.
|
||||||
|
|
||||||
## Maintenance
|
## Maintenance
|
||||||
|
|
||||||
At the top of the card, the last row, **Maintenance** (or <kbd>m</kbd>), shows the card's usage and holds **Storage clean-up** and **Erase SD card**. They delete for good, so they sit behind a warning. Clean-up deletes old logs and captures by category and age, showing the space it would free first. Notes and Saved Pages are never offered.
|
At the top of the card, the last row, **Maintenance** (or <kbd>m</kbd>), shows the card's usage and holds **Storage clean-up** and **Erase SD card**. They delete for good, so they sit behind a warning. Clean-up deletes old logs and captures by category and age, showing the space it would free first. Notes and Saved Pages are never offered.
|
||||||
@@ -50,4 +61,4 @@ The firmware warns once per start when the card passes **80%** full; past **90%*
|
|||||||
|
|
||||||
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones.
|
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones.
|
||||||
|
|
||||||
{{ keys(scopes=["storage", "storage-details", "storage-name", "storage-busy", "maintenance", "viewer-text", "viewer-hex", "viewer-pcap", "viewer-packet", "viewer-gpx", "viewer-ota"]) }}
|
{{ keys(scopes=["storage", "storage-details", "storage-name", "storage-busy", "maintenance", "viewer-text", "viewer-hex", "viewer-pcap", "viewer-packet", "viewer-gpx", "viewer-ota", "viewer-image"]) }}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
+++
|
||||||
|
title = "Search"
|
||||||
|
description = "Find a word in the user guide, the how-tos, the questions and answers, and the developer docs."
|
||||||
|
template = "search.html"
|
||||||
|
+++
|
||||||
@@ -9,6 +9,7 @@ rows = [
|
|||||||
["Fn `", "home, the Launcher"],
|
["Fn `", "home, the Launcher"],
|
||||||
["; . , /", "arrows (Fn+ while typing)"],
|
["; . , /", "arrows (Fn+ while typing)"],
|
||||||
["Fn h ?", "these keys (? not typing)"],
|
["Fn h ?", "these keys (? not typing)"],
|
||||||
|
["Fn p", "a screenshot, on the card"],
|
||||||
]
|
]
|
||||||
|
|
||||||
[[scope]]
|
[[scope]]
|
||||||
@@ -338,6 +339,16 @@ rows = [
|
|||||||
["Tab", "the file as hex"],
|
["Tab", "the file as hex"],
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[scope]]
|
||||||
|
id = "viewer-image"
|
||||||
|
title = "A picture"
|
||||||
|
rows = [
|
||||||
|
["Enter", "its own size, or all of it"],
|
||||||
|
["; . , /", "move around it"],
|
||||||
|
["i", "its size"],
|
||||||
|
["Tab", "the file as hex"],
|
||||||
|
]
|
||||||
|
|
||||||
[[scope]]
|
[[scope]]
|
||||||
id = "notes"
|
id = "notes"
|
||||||
title = "Notes, the list"
|
title = "Notes, the list"
|
||||||
|
|||||||
@@ -263,3 +263,26 @@ footer small { display: block; max-width: 760px; }
|
|||||||
.prose .keys td { padding: 4px 8px 4px 0; border-bottom: 0; font-size: 15px; }
|
.prose .keys td { padding: 4px 8px 4px 0; border-bottom: 0; font-size: 15px; }
|
||||||
.prose .keys td:first-child { white-space: nowrap; width: 1%; padding-right: 16px; }
|
.prose .keys td:first-child { white-space: nowrap; width: 1%; padding-right: 16px; }
|
||||||
.prose .keys kbd { white-space: pre; }
|
.prose .keys kbd { white-space: pre; }
|
||||||
|
|
||||||
|
/* Search (issue #60): the search page's box, its results and its index; the small box on the
|
||||||
|
documentation's index pages. */
|
||||||
|
.search-form { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 24px 0 8px; max-width: 720px; }
|
||||||
|
.search-form label { flex-basis: 100%; font: 400 14px/20px var(--mono); color: var(--muted); }
|
||||||
|
.search-form input { flex: 1 1 220px; min-width: 0; min-height: 44px; padding: 0 12px; font: 400 16px/24px var(--sans); color: var(--ink); background: var(--s2); border: 1px solid var(--line); }
|
||||||
|
.search-form input:focus-visible { outline: 2px solid var(--cyan); outline-offset: 2px; }
|
||||||
|
.search-mini { margin: 16px 0 32px; max-width: 520px; }
|
||||||
|
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
||||||
|
.s-status { min-height: 24px; margin: 8px 0; }
|
||||||
|
.s-results, .s-index ul { list-style: none; margin: 0; padding: 0; max-width: 720px; }
|
||||||
|
.s-results li { padding: 16px 0; border-top: 1px solid var(--line); }
|
||||||
|
.s-results a { font: 500 18px/24px var(--sans); }
|
||||||
|
.s-results p { margin: 4px 0 0; color: var(--muted); overflow-wrap: anywhere; }
|
||||||
|
.s-results mark { background: none; color: var(--orangetext); font-weight: 600; }
|
||||||
|
.s-where { display: block; font: 400 12px/16px var(--mono); color: var(--muted); }
|
||||||
|
.s-index section { margin: 0 0 32px; }
|
||||||
|
.s-index h2 { font: 500 14px/20px var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--cyantext); }
|
||||||
|
.s-index li { padding: 2px 0; }
|
||||||
|
.s-index li.s-part { padding-left: 20px; }
|
||||||
|
.s-index .s-where, .s-index .s-text { display: none; }
|
||||||
|
.s-index a:hover { text-decoration: underline; text-underline-offset: 4px; }
|
||||||
|
.js .s-nojs { display: none; }
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
// The search page (issue #60). The index is the page itself: one list item for each page of the
|
||||||
|
// documentation and each of its headings, with that part's text. This filters and ranks them.
|
||||||
|
// Nothing is fetched, and nothing typed here leaves the browser.
|
||||||
|
(function () {
|
||||||
|
// Where a match counts for more: what a user came for before what a developer wrote down.
|
||||||
|
var weight = { "Guide": 1.4, "How-to": 1.3, "FAQ": 1.3, "Decisions": 0.8, "Milestones": 0.5 };
|
||||||
|
var kMax = 40, kAround = 90;
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
var input = document.getElementById("q"), results = document.getElementById("s-results");
|
||||||
|
var status = document.getElementById("s-status"), index = document.getElementById("s-index");
|
||||||
|
if (!input || !results || !index) return;
|
||||||
|
|
||||||
|
var entries = Array.prototype.map.call(index.querySelectorAll(".s-entry"), function (li) {
|
||||||
|
var link = li.querySelector("a"), where = li.querySelector(".s-where"), text = li.querySelector(".s-text");
|
||||||
|
var body = text ? text.textContent.replace(/\s+/g, " ").trim() : "";
|
||||||
|
return {
|
||||||
|
href: link.getAttribute("href"), title: link.textContent, where: where ? where.textContent : "",
|
||||||
|
body: body, titleLow: link.textContent.toLowerCase(), whereLow: (where ? where.textContent : "").toLowerCase(),
|
||||||
|
bodyLow: body.toLowerCase(), weight: weight[li.getAttribute("data-group")] || 1
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function count(hay, word) {
|
||||||
|
var n = 0, at = hay.indexOf(word);
|
||||||
|
while (at >= 0 && n < 5) { n++; at = hay.indexOf(word, at + word.length); }
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function search(words) {
|
||||||
|
var hits = [], phrase = words.join(" ");
|
||||||
|
entries.forEach(function (e) {
|
||||||
|
// The words as typed, side by side, count for more than the same words scattered.
|
||||||
|
var score = words.length > 1 ? (e.titleLow.indexOf(phrase) >= 0 ? 30 : 0) + (e.bodyLow.indexOf(phrase) >= 0 ? 12 : 0) : 0;
|
||||||
|
if (e.titleLow === phrase) score += 20;
|
||||||
|
for (var i = 0; i < words.length; i++) {
|
||||||
|
var w = words[i], inTitle = e.titleLow.indexOf(w) >= 0, inWhere = e.whereLow.indexOf(w) >= 0, n = count(e.bodyLow, w);
|
||||||
|
if (!inTitle && !inWhere && !n) return; // every word has to be there
|
||||||
|
score += (inTitle ? 20 : 0) + (inWhere ? 3 : 0) + n;
|
||||||
|
}
|
||||||
|
hits.push({ entry: e, score: score * e.weight });
|
||||||
|
});
|
||||||
|
hits.sort(function (a, b) { return b.score - a.score; });
|
||||||
|
return hits;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The text around the first word found, with every word marked.
|
||||||
|
function snippet(e, words) {
|
||||||
|
var p = document.createElement("p"), first = -1;
|
||||||
|
words.forEach(function (w) {
|
||||||
|
var at = e.bodyLow.indexOf(w);
|
||||||
|
if (at >= 0 && (first < 0 || at < first)) first = at;
|
||||||
|
});
|
||||||
|
var from = Math.max(0, (first < 0 ? 0 : first) - kAround), to = Math.min(e.body.length, from + 2 * kAround + 40);
|
||||||
|
if (from > 0) { var space = e.body.indexOf(" ", from); if (space >= 0 && space < from + 20) from = space + 1; }
|
||||||
|
var piece = e.body.slice(from, to), low = piece.toLowerCase(), at = 0;
|
||||||
|
if (from > 0) p.appendChild(document.createTextNode("… "));
|
||||||
|
while (at < piece.length) {
|
||||||
|
var next = -1, len = 0;
|
||||||
|
words.forEach(function (w) {
|
||||||
|
var i = low.indexOf(w, at);
|
||||||
|
if (i >= 0 && (next < 0 || i < next)) { next = i; len = w.length; }
|
||||||
|
});
|
||||||
|
if (next < 0) { p.appendChild(document.createTextNode(piece.slice(at))); break; }
|
||||||
|
if (next > at) p.appendChild(document.createTextNode(piece.slice(at, next)));
|
||||||
|
var mark = document.createElement("mark");
|
||||||
|
mark.textContent = piece.slice(next, next + len);
|
||||||
|
p.appendChild(mark);
|
||||||
|
at = next + len;
|
||||||
|
}
|
||||||
|
if (to < e.body.length) p.appendChild(document.createTextNode(" …"));
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function show() {
|
||||||
|
var q = input.value.trim(), words = q.toLowerCase().split(/\s+/).filter(function (w) { return w.length > 0; });
|
||||||
|
while (results.firstChild) results.removeChild(results.firstChild);
|
||||||
|
try { history.replaceState(null, "", q ? "?q=" + encodeURIComponent(q) : location.pathname); } catch (e) { /* a file: page */ }
|
||||||
|
if (!words.length) {
|
||||||
|
results.hidden = true;
|
||||||
|
index.hidden = false;
|
||||||
|
status.textContent = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var hits = search(words);
|
||||||
|
hits.slice(0, kMax).forEach(function (h) {
|
||||||
|
var li = document.createElement("li"), a = document.createElement("a"), where = document.createElement("span");
|
||||||
|
a.href = h.entry.href;
|
||||||
|
a.className = "accent-link";
|
||||||
|
a.textContent = h.entry.title;
|
||||||
|
where.className = "s-where";
|
||||||
|
where.textContent = h.entry.where;
|
||||||
|
li.appendChild(a);
|
||||||
|
li.appendChild(where);
|
||||||
|
li.appendChild(snippet(h.entry, words));
|
||||||
|
results.appendChild(li);
|
||||||
|
});
|
||||||
|
results.hidden = false;
|
||||||
|
index.hidden = true;
|
||||||
|
status.textContent = !hits.length ? "Nothing found for “" + q + "”. Every word has to be on the page."
|
||||||
|
: (hits.length > kMax ? "The first " + kMax + " of " + hits.length : hits.length === 1 ? "1 place" : hits.length + " places") + " for “" + q + "”.";
|
||||||
|
}
|
||||||
|
|
||||||
|
var timer = 0;
|
||||||
|
input.addEventListener("input", function () {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(show, 80);
|
||||||
|
});
|
||||||
|
input.form.addEventListener("submit", function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
show();
|
||||||
|
});
|
||||||
|
var asked = /[?&]q=([^&]*)/.exec(location.search);
|
||||||
|
if (asked) {
|
||||||
|
try { input.value = decodeURIComponent(asked[1].replace(/\+/g, " ")); } catch (e) { /* a bad escape: an empty box */ }
|
||||||
|
}
|
||||||
|
show();
|
||||||
|
input.focus();
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -33,6 +33,7 @@
|
|||||||
<a href="/dev/">Developers</a>
|
<a href="/dev/">Developers</a>
|
||||||
<a href="/downloads/">Downloads</a>
|
<a href="/downloads/">Downloads</a>
|
||||||
<a href="/devlog/">Devlog</a>
|
<a href="/devlog/">Devlog</a>
|
||||||
|
<a href="/search/">Search</a>
|
||||||
<button class="link-btn js-only" id="theme-toggle" type="button">Light</button>
|
<button class="link-btn js-only" id="theme-toggle" type="button">Light</button>
|
||||||
<a class="btn btn-primary n-md" href="{{ config.extra.repo }}">Source</a>
|
<a class="btn btn-primary n-md" href="{{ config.extra.repo }}">Source</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -11,6 +11,12 @@
|
|||||||
|
|
||||||
<div class="prose">{{ section.content | safe }}</div>
|
<div class="prose">{{ section.content | safe }}</div>
|
||||||
|
|
||||||
|
<form class="search-form search-mini" action="/search/" method="get" role="search">
|
||||||
|
<label class="sr" for="q">Search the documentation</label>
|
||||||
|
<input id="q" name="q" type="search" autocomplete="off" spellcheck="false" placeholder="Search the documentation">
|
||||||
|
<button class="btn n-md" type="submit">Search</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
<div class="cards">
|
<div class="cards">
|
||||||
{% for path in section.subsections %}
|
{% for path in section.subsections %}
|
||||||
{% set sub = get_section(path=path) %}
|
{% set sub = get_section(path=path) %}
|
||||||
|
|||||||
@@ -11,6 +11,12 @@
|
|||||||
|
|
||||||
<div class="prose">{{ section.content | safe }}</div>
|
<div class="prose">{{ section.content | safe }}</div>
|
||||||
|
|
||||||
|
<form class="search-form search-mini" action="/search/" method="get" role="search">
|
||||||
|
<label class="sr" for="q">Search the documentation</label>
|
||||||
|
<input id="q" name="q" type="search" autocomplete="off" spellcheck="false" placeholder="Search the documentation">
|
||||||
|
<button class="btn n-md" type="submit">Search</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
<div class="cards">
|
<div class="cards">
|
||||||
{% for p in section.pages %}
|
{% for p in section.pages %}
|
||||||
<article class="card n-lg">
|
<article class="card n-lg">
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{# One entry of the search page for each part of a page: what comes before its first heading,
|
||||||
|
then each "## heading" with what follows it. The text is the page's own, tags taken out. #}
|
||||||
|
{% macro entries(page, group) %}
|
||||||
|
{% set parts = page.content | split(pat='<h2 id="') %}
|
||||||
|
{% for part in parts %}
|
||||||
|
{% if loop.first %}
|
||||||
|
<li class="s-entry" data-group="{{ group }}"><a href="{{ page.path | safe }}">{{ page.title }}</a><span class="s-where">{{ group }}</span><p class="s-text">{{ page.description }} {{ part | striptags | trim | safe }}</p></li>
|
||||||
|
{% else %}
|
||||||
|
{% set anchor = part | split(pat='"') | first %}
|
||||||
|
{% set head = part | split(pat="</h2>") | first %}
|
||||||
|
{% set heading = head | split(pat='">') | slice(start=1) | join(sep='">') | striptags | trim %}
|
||||||
|
{% set body = part | split(pat="</h2>") | slice(start=1) | join(sep="</h2>") | striptags | trim %}
|
||||||
|
<li class="s-entry s-part" data-group="{{ group }}"><a href="{{ page.path | safe }}#{{ anchor }}">{{ heading | safe }}</a><span class="s-where">{{ group }} · {{ page.title }}</span><p class="s-text">{{ body | safe }}</p></li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endmacro entries %}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% import "macros/search.html" as search %}
|
||||||
|
{% block title %}{{ page.title }}: roro9stack{% endblock %}
|
||||||
|
{% block description %}{{ page.description }}{% endblock %}
|
||||||
|
{% block head %}<script src="/js/search.js" defer></script>{% endblock %}
|
||||||
|
{% block main %}
|
||||||
|
{# The whole index is in this page (issue #60): nothing is fetched, and without JavaScript it is
|
||||||
|
still a list of every page and heading of the documentation. js/search.js filters it. #}
|
||||||
|
<div class="wrap page">
|
||||||
|
<header>
|
||||||
|
<span class="eyebrow">Documentation</span>
|
||||||
|
<h1>{{ page.title }}</h1>
|
||||||
|
<p class="lead">{{ page.description }}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form class="search-form" action="/search/" method="get" role="search">
|
||||||
|
<label for="q">Search the guide, the how-tos, the FAQ and the developer docs</label>
|
||||||
|
<input id="q" name="q" type="search" autocomplete="off" spellcheck="false" placeholder="Probation, Safe Mode, rm -r, ...">
|
||||||
|
<button class="btn btn-primary n-md" type="submit">Search</button>
|
||||||
|
</form>
|
||||||
|
<p class="s-status muted" id="s-status" role="status" aria-live="polite"></p>
|
||||||
|
<ol class="s-results" id="s-results" hidden></ol>
|
||||||
|
|
||||||
|
<div class="s-index" id="s-index">
|
||||||
|
<p class="muted s-nojs">Every page of the documentation and its headings. With JavaScript on, the box above searches their text.</p>
|
||||||
|
{% set guide = get_section(path="guide/_index.md") %}
|
||||||
|
<section><h2>{{ guide.title }}</h2><ul>
|
||||||
|
{% for p in guide.pages %}{{ search::entries(page=p, group="Guide") }}{% endfor %}
|
||||||
|
</ul></section>
|
||||||
|
{% set howto = get_section(path="howto/_index.md") %}
|
||||||
|
<section><h2>{{ howto.title }}</h2><ul>
|
||||||
|
{% for p in howto.pages %}{{ search::entries(page=p, group="How-to") }}{% endfor %}
|
||||||
|
</ul></section>
|
||||||
|
<section><h2>Questions and answers</h2><ul>
|
||||||
|
{{ search::entries(page=get_page(path="faq.md"), group="FAQ") }}
|
||||||
|
</ul></section>
|
||||||
|
{% set dev = get_section(path="dev/_index.md") %}
|
||||||
|
{% for path in dev.subsections %}
|
||||||
|
{% set sub = get_section(path=path) %}
|
||||||
|
<section><h2>Developers: {{ sub.title }}</h2><ul>
|
||||||
|
{% for p in sub.pages %}{{ search::entries(page=p, group=sub.extra.search | default(value=sub.title)) }}{% endfor %}
|
||||||
|
</ul></section>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock main %}
|
||||||
@@ -162,11 +162,15 @@ void FileViewer::open(const std::string& path, uint32_t size) {
|
|||||||
file_ = std::make_shared<fs::File>();
|
file_ = std::make_shared<fs::File>();
|
||||||
std::string name = files::baseName(path);
|
std::string name = files::baseName(path);
|
||||||
files::FileKind kind = files::kindOf(name);
|
files::FileKind kind = files::kindOf(name);
|
||||||
if (kind == files::FileKind::Unknown) { // what do its first bytes look like?
|
files::ImageKind picture = files::imageKindOfName(name);
|
||||||
|
if (kind == files::FileKind::Unknown && picture == files::ImageKind::None) { // what do its first bytes look like?
|
||||||
uint8_t head[256];
|
uint8_t head[256];
|
||||||
size_t n = readAt(0, head, sizeof head);
|
size_t n = readAt(0, head, sizeof head);
|
||||||
|
picture = files::imageKindOfBytes(head, n);
|
||||||
kind = files::looksLikeText(head, n) ? files::FileKind::Text : files::FileKind::Unknown;
|
kind = files::looksLikeText(head, n) ? files::FileKind::Text : files::FileKind::Unknown;
|
||||||
}
|
}
|
||||||
|
std::string notShown;
|
||||||
|
if (picture != files::ImageKind::None) notShown = image_.open(path, size);
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
case files::FileKind::Text: base_ = Mode::Text; break;
|
case files::FileKind::Text: base_ = Mode::Text; break;
|
||||||
case files::FileKind::Gpx: base_ = Mode::Gpx; break;
|
case files::FileKind::Gpx: base_ = Mode::Gpx; break;
|
||||||
@@ -174,11 +178,13 @@ void FileViewer::open(const std::string& path, uint32_t size) {
|
|||||||
case files::FileKind::Ota: base_ = Mode::Ota; break;
|
case files::FileKind::Ota: base_ = Mode::Ota; break;
|
||||||
default: base_ = Mode::Hex; break;
|
default: base_ = Mode::Hex; break;
|
||||||
}
|
}
|
||||||
|
if (picture != files::ImageKind::None && notShown.empty()) base_ = Mode::Image;
|
||||||
pager_.reset(new files::TextPager([this](uint32_t offset, uint8_t* into, size_t len) { return readAt(offset, into, len); }, size_,
|
pager_.reset(new files::TextPager([this](uint32_t offset, uint8_t* into, size_t len) { return readAt(offset, into, len); }, size_,
|
||||||
kCols, kRows));
|
kCols, kRows));
|
||||||
if (files::opensAtEnd(name)) pager_->toEnd();
|
if (files::opensAtEnd(name)) pager_->toEnd();
|
||||||
hexTop_ = 0;
|
hexTop_ = 0;
|
||||||
show(base_);
|
show(base_);
|
||||||
|
if (!notShown.empty()) say(notShown); // a picture that can't be shown: its bytes, and why
|
||||||
if (base_ == Mode::Gpx || base_ == Mode::Pcap || base_ == Mode::Ota) startScan(base_);
|
if (base_ == Mode::Gpx || base_ == Mode::Pcap || base_ == Mode::Ota) startScan(base_);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,6 +209,7 @@ void FileViewer::close() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
file_.reset();
|
file_.reset();
|
||||||
|
image_.close();
|
||||||
pager_.reset();
|
pager_.reset();
|
||||||
confirm_.reset();
|
confirm_.reset();
|
||||||
message_.clear();
|
message_.clear();
|
||||||
@@ -214,11 +221,13 @@ void FileViewer::close() {
|
|||||||
|
|
||||||
void FileViewer::show(Mode mode) {
|
void FileViewer::show(Mode mode) {
|
||||||
mode_ = mode;
|
mode_ = mode;
|
||||||
|
if (mode == Mode::Image) image_.lost(); // another view was drawn where it was
|
||||||
stale_ = true;
|
stale_ = true;
|
||||||
rowsFrom_ = -1;
|
rowsFrom_ = -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
void FileViewer::say(const std::string& text) {
|
void FileViewer::say(const std::string& text) {
|
||||||
|
if (mode_ == Mode::Image) return image_.say(text);
|
||||||
message_ = text;
|
message_ = text;
|
||||||
messageMs_ = millis();
|
messageMs_ = millis();
|
||||||
}
|
}
|
||||||
@@ -275,6 +284,7 @@ void FileViewer::help(std::vector<KeyHelp>& out) const {
|
|||||||
case Mode::Packet: keys::add(out, keys::kViewerPacket); break;
|
case Mode::Packet: keys::add(out, keys::kViewerPacket); break;
|
||||||
case Mode::Gpx: keys::add(out, keys::kViewerGpx); break;
|
case Mode::Gpx: keys::add(out, keys::kViewerGpx); break;
|
||||||
case Mode::Ota: keys::add(out, keys::kViewerOta); break;
|
case Mode::Ota: keys::add(out, keys::kViewerOta); break;
|
||||||
|
case Mode::Image: image_.help(out); break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,6 +309,7 @@ bool FileViewer::onKey(const KeyEvent& e) {
|
|||||||
else show(base_ == Mode::Hex || base_ == Mode::Gpx ? Mode::Text : Mode::Hex);
|
else show(base_ == Mode::Hex || base_ == Mode::Gpx ? Mode::Text : Mode::Hex);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (mode_ == Mode::Image) return image_.onKey(e), true;
|
||||||
uint32_t ch = e.key == Key::Char ? e.ch : 0;
|
uint32_t ch = e.key == Key::Char ? e.ch : 0;
|
||||||
switch (mode_) {
|
switch (mode_) {
|
||||||
case Mode::Text:
|
case Mode::Text:
|
||||||
@@ -332,7 +343,8 @@ bool FileViewer::onKey(const KeyEvent& e) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool FileViewer::update(uint32_t) {
|
bool FileViewer::update(uint32_t nowMs) {
|
||||||
|
if (mode_ == Mode::Image) return image_.update(nowMs);
|
||||||
if (!message_.empty() && millis() - messageMs_ >= 4000) {
|
if (!message_.empty() && millis() - messageMs_ >= 4000) {
|
||||||
message_.clear();
|
message_.clear();
|
||||||
return true;
|
return true;
|
||||||
@@ -389,6 +401,7 @@ void FileViewer::refresh() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void FileViewer::draw(Canvas& c) {
|
void FileViewer::draw(Canvas& c) {
|
||||||
|
if (mode_ == Mode::Image) return image_.draw(c);
|
||||||
const auto& area = theme::kContent;
|
const auto& area = theme::kContent;
|
||||||
refresh();
|
refresh();
|
||||||
c.setTextDatum(top_left);
|
c.setTextDatum(top_left);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
|
#include "apps/image_pane.h"
|
||||||
#include "dialog_model.h"
|
#include "dialog_model.h"
|
||||||
#include "key_help.h"
|
#include "key_help.h"
|
||||||
#include "key_event.h"
|
#include "key_event.h"
|
||||||
@@ -23,7 +24,7 @@ namespace roro {
|
|||||||
// genuine. Tab switches to the text or the hex of the same file. Nothing here changes a file.
|
// genuine. Tab switches to the text or the hex of the same file. Nothing here changes a file.
|
||||||
class FileViewer {
|
class FileViewer {
|
||||||
public:
|
public:
|
||||||
FileViewer(StorageService& storage, UpdateService& update) : storage_(storage), update_(update) {}
|
FileViewer(StorageService& storage, UpdateService& update) : storage_(storage), update_(update), image_(storage) {}
|
||||||
|
|
||||||
void open(const std::string& path, uint32_t size);
|
void open(const std::string& path, uint32_t size);
|
||||||
void close();
|
void close();
|
||||||
@@ -37,9 +38,12 @@ class FileViewer {
|
|||||||
const std::string& path() const { return path_; }
|
const std::string& path() const { return path_; }
|
||||||
uint32_t size() const { return size_; }
|
uint32_t size() const { return size_; }
|
||||||
void say(const std::string& text);
|
void say(const std::string& text);
|
||||||
|
// A picture is drawn once and kept on the screen (App::retainsContent).
|
||||||
|
bool retains() const { return mode_ == Mode::Image; }
|
||||||
|
void lost() { image_.lost(); }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
enum class Mode { Text, Hex, Gpx, Pcap, Packet, Ota };
|
enum class Mode { Text, Hex, Gpx, Pcap, Packet, Ota, Image };
|
||||||
static constexpr int kRows = 8;
|
static constexpr int kRows = 8;
|
||||||
static constexpr int kCols = 38;
|
static constexpr int kCols = 38;
|
||||||
struct Scan; // what a storage job reads through a whole file for: shared with that job
|
struct Scan; // what a storage job reads through a whole file for: shared with that job
|
||||||
@@ -54,6 +58,7 @@ class FileViewer {
|
|||||||
|
|
||||||
StorageService& storage_;
|
StorageService& storage_;
|
||||||
UpdateService& update_;
|
UpdateService& update_;
|
||||||
|
ImagePane image_;
|
||||||
std::string path_;
|
std::string path_;
|
||||||
uint32_t size_ = 0;
|
uint32_t size_ = 0;
|
||||||
Mode mode_ = Mode::Text, base_ = Mode::Text;
|
Mode mode_ = Mode::Text, base_ = Mode::Text;
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
#include "image_pane.h"
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <SD.h>
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <cstring>
|
||||||
|
#include <memory>
|
||||||
|
#include <new>
|
||||||
|
|
||||||
|
#include <lgfx/utility/lgfx_tjpgd.h>
|
||||||
|
|
||||||
|
#include "app_keys.h"
|
||||||
|
#include "file_names.h"
|
||||||
|
#include "platform/console.h"
|
||||||
|
#include "png_reader.h"
|
||||||
|
#include "ui/fonts.h"
|
||||||
|
#include "ui/theme.h"
|
||||||
|
|
||||||
|
namespace roro {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
constexpr uint32_t kNoteMs = 3000;
|
||||||
|
constexpr uint32_t kPushMs = 250; // how often the screen shows how far the decoding is
|
||||||
|
constexpr int kNoteHeight = 11;
|
||||||
|
constexpr size_t kJpegPool = 3900; // what the library gives its own JPEG decoder
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
struct ImagePane::Job {
|
||||||
|
std::string path, why;
|
||||||
|
files::ImageInfo info;
|
||||||
|
files::ImageFrame frame;
|
||||||
|
files::ImageMap map;
|
||||||
|
uint32_t size = 0, shotAt = 0, tookMs = 0;
|
||||||
|
uint8_t* screen = nullptr; // the screen's buffer: one byte a pixel, RRRGGGBB
|
||||||
|
int stride = 0;
|
||||||
|
std::atomic<bool> stop{false}, done{false};
|
||||||
|
bool enough = false; // the rest of the file is under the view: the decoder is told to stop
|
||||||
|
File* file = nullptr;
|
||||||
|
uint32_t sinceRest = 0;
|
||||||
|
|
||||||
|
void put(int sx, int sy, uint8_t r, uint8_t g, uint8_t b) {
|
||||||
|
int tx, ty;
|
||||||
|
if (map.at(sx, sy, tx, ty)) screen[ty * stride + tx] = files::rgb332Dithered(r, g, b, tx, ty);
|
||||||
|
}
|
||||||
|
// A long decoding must leave the processor to others now and then (the idle task is watched).
|
||||||
|
void rest(uint32_t bytes) {
|
||||||
|
sinceRest += bytes;
|
||||||
|
if (sinceRest < 16 * 1024) return;
|
||||||
|
sinceRest = 0;
|
||||||
|
vTaskDelay(1);
|
||||||
|
}
|
||||||
|
size_t readAt(uint32_t at, uint8_t* into, size_t len) {
|
||||||
|
if (stop || !file->seek(at)) return 0;
|
||||||
|
int n = file->read(into, len);
|
||||||
|
rest(static_cast<uint32_t>(len));
|
||||||
|
return n > 0 ? static_cast<size_t>(n) : 0;
|
||||||
|
}
|
||||||
|
void run();
|
||||||
|
};
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// The library's JPEG decoder reads the file from its start on; a null buffer means "skip".
|
||||||
|
// Nothing more to read is how a decoding is told to stop.
|
||||||
|
uint32_t readNext(void* job, uint8_t* into, uint32_t len) {
|
||||||
|
auto& j = *static_cast<ImagePane::Job*>(job);
|
||||||
|
if (j.stop || j.enough) return 0;
|
||||||
|
File& f = *j.file;
|
||||||
|
j.rest(len);
|
||||||
|
if (!into) return f.seek(f.position() + len) ? len : 0;
|
||||||
|
int n = f.read(into, len);
|
||||||
|
return n > 0 ? static_cast<uint32_t>(n) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A block of a JPEG: its pixels row by row, three bytes each.
|
||||||
|
uint32_t jpegBlock(void* job, void* bitmap, JRECT* rect) {
|
||||||
|
auto& j = *static_cast<ImagePane::Job*>(job);
|
||||||
|
const uint8_t* p = static_cast<const uint8_t*>(bitmap);
|
||||||
|
if (j.map.below(static_cast<int>(rect->top))) return j.enough = true, 0;
|
||||||
|
for (uint32_t y = rect->top; y <= rect->bottom; y++)
|
||||||
|
for (uint32_t x = rect->left; x <= rect->right; x++, p += 3) j.put(static_cast<int>(x), static_cast<int>(y), p[0], p[1], p[2]);
|
||||||
|
return j.stop ? 0 : 1;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
// On the storage task.
|
||||||
|
void ImagePane::Job::run() {
|
||||||
|
uint32_t started = millis();
|
||||||
|
File f = SD.open(path.c_str(), FILE_READ);
|
||||||
|
if (!f) {
|
||||||
|
why = "The card refused to open it";
|
||||||
|
done = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
file = &f;
|
||||||
|
files::ImageRead read = [this](uint32_t at, uint8_t* into, size_t len) { return readAt(at, into, len); };
|
||||||
|
files::ImagePixels pixels = [this](int x, int y, int count, const uint8_t* rgb) {
|
||||||
|
for (int i = 0; i < count; i++, rgb += 3) put(x + i, y, rgb[0], rgb[1], rgb[2]);
|
||||||
|
};
|
||||||
|
switch (info.kind) {
|
||||||
|
case files::ImageKind::Png:
|
||||||
|
if (shotAt) { // one of ours: each byte is already a colour of the screen
|
||||||
|
std::unique_ptr<uint8_t[]> row(new (std::nothrow) uint8_t[info.width]);
|
||||||
|
if (!row) {
|
||||||
|
why = "Not enough memory";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
for (int y = 0; y < info.height && why.empty() && !stop; y++) {
|
||||||
|
if (!map.rowUsed(y)) continue;
|
||||||
|
size_t w = static_cast<size_t>(info.width);
|
||||||
|
if (readAt(shotAt + static_cast<uint32_t>(y) * (info.width + 1), row.get(), w) != w) why = "The card refused to read it";
|
||||||
|
int tx, ty;
|
||||||
|
for (int x = 0; x < info.width; x++)
|
||||||
|
if (map.at(x, y, tx, ty)) screen[ty * stride + tx] = row[x];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
why = files::readPng(read, size, pixels, [this](int y) { return map.rowUsed(y); }, [this](int y) { return map.below(y); });
|
||||||
|
break;
|
||||||
|
case files::ImageKind::Jpeg: {
|
||||||
|
std::unique_ptr<lgfxJdec> jpeg(new (std::nothrow) lgfxJdec);
|
||||||
|
std::unique_ptr<uint8_t[]> pool(new (std::nothrow) uint8_t[kJpegPool]);
|
||||||
|
if (!jpeg || !pool) {
|
||||||
|
why = "Not enough memory";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
int shrink = frame.jpegShrink();
|
||||||
|
map = frame.map(shrink);
|
||||||
|
JRESULT r = lgfx_jd_prepare(jpeg.get(), readNext, pool.get(), kJpegPool, this);
|
||||||
|
if (r == JDR_OK) r = lgfx_jd_decomp(jpeg.get(), jpegBlock, static_cast<uint_fast8_t>(shrink));
|
||||||
|
if (r == JDR_FMT3) why = "This kind of JPEG can't be shown";
|
||||||
|
else if (r == JDR_MEM1 || r == JDR_MEM2) why = "This JPEG is too complex to show";
|
||||||
|
else if (r != JDR_OK && !enough) why = "This JPEG is damaged";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case files::ImageKind::Bmp:
|
||||||
|
why = files::readBmp(read, size, pixels, [this](int y) { return map.rowUsed(y); });
|
||||||
|
break;
|
||||||
|
case files::ImageKind::Gif: why = files::readGif(read, size, pixels); break;
|
||||||
|
default: why = "Not a picture this can show"; break;
|
||||||
|
}
|
||||||
|
f.close();
|
||||||
|
file = nullptr;
|
||||||
|
tookMs = millis() - started;
|
||||||
|
if (stop) why.clear();
|
||||||
|
else
|
||||||
|
console.printf("image: %s, %d x %d %s, %s in %u ms\n", path.c_str(), info.width, info.height, files::imageKindName(info.kind),
|
||||||
|
why.empty() ? (frame.actual() ? "its own size" : "fitted") : why.c_str(), (unsigned)tookMs);
|
||||||
|
done = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string ImagePane::open(const std::string& path, uint32_t size) {
|
||||||
|
close();
|
||||||
|
std::string why;
|
||||||
|
files::ImageInfo info;
|
||||||
|
uint32_t shotAt = 0;
|
||||||
|
bool ran = storage_.runAndWait([&]() {
|
||||||
|
File f = SD.open(path.c_str(), FILE_READ);
|
||||||
|
if (!f) {
|
||||||
|
why = "The card refused to open it";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
files::ImageRead read = [&f](uint32_t at, uint8_t* into, size_t len) -> size_t {
|
||||||
|
if (!f.seek(at)) return 0;
|
||||||
|
int n = f.read(into, len);
|
||||||
|
return n > 0 ? static_cast<size_t>(n) : 0;
|
||||||
|
};
|
||||||
|
why = files::imageInfo(read, size, info);
|
||||||
|
if (why.empty() && info.kind == files::ImageKind::Png) shotAt = files::screenshotPixelsAt(read, size, info.width, info.height);
|
||||||
|
f.close();
|
||||||
|
});
|
||||||
|
if (!ran) why = "No SD card";
|
||||||
|
if (!why.empty()) return why;
|
||||||
|
path_ = path;
|
||||||
|
size_ = size;
|
||||||
|
info_ = info;
|
||||||
|
shotAt_ = shotAt;
|
||||||
|
const auto& area = theme::kContent;
|
||||||
|
frame_ = files::ImageFrame(info.width, info.height, area.x, area.y, area.w, area.h);
|
||||||
|
phase_ = Phase::Wanted;
|
||||||
|
told_ = noteDrawn_ = false;
|
||||||
|
problem_.clear();
|
||||||
|
note_.clear();
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
void ImagePane::cancel() {
|
||||||
|
if (job_ && !job_->done) {
|
||||||
|
job_->stop = true;
|
||||||
|
storage_.runAndWait([]() {}); // behind the decoding in the queue: back when it has ended
|
||||||
|
}
|
||||||
|
job_.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ImagePane::close() {
|
||||||
|
cancel();
|
||||||
|
path_.clear();
|
||||||
|
problem_.clear();
|
||||||
|
note_.clear();
|
||||||
|
info_ = files::ImageInfo();
|
||||||
|
phase_ = Phase::Wanted;
|
||||||
|
std::vector<uint8_t>().swap(under_);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ImagePane::lost() {
|
||||||
|
cancel();
|
||||||
|
phase_ = Phase::Wanted;
|
||||||
|
noteDrawn_ = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string ImagePane::describe() const {
|
||||||
|
std::string s = std::to_string(info_.width) + " x " + std::to_string(info_.height) + " " + files::imageKindName(info_.kind);
|
||||||
|
if (frame_.bigger()) s += frame_.actual() ? ", its own size" : ", at " + std::to_string(frame_.percent()) + " %";
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ImagePane::say(const std::string& text) {
|
||||||
|
hideNote(canvas_);
|
||||||
|
note_ = text;
|
||||||
|
noteMs_ = millis();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ImagePane::help(std::vector<KeyHelp>& out) const { keys::add(out, keys::kViewerImage); }
|
||||||
|
|
||||||
|
bool ImagePane::onKey(const KeyEvent& e) {
|
||||||
|
if (path_.empty()) return false;
|
||||||
|
bool changed = false;
|
||||||
|
switch (e.key) {
|
||||||
|
case Key::Select:
|
||||||
|
if (!frame_.bigger()) return true;
|
||||||
|
cancel(); // before the frame it is drawing into changes
|
||||||
|
frame_.toggle();
|
||||||
|
told_ = false;
|
||||||
|
changed = true;
|
||||||
|
break;
|
||||||
|
case Key::Up:
|
||||||
|
case Key::Down:
|
||||||
|
case Key::Left:
|
||||||
|
case Key::Right: {
|
||||||
|
if (!frame_.actual()) return true;
|
||||||
|
cancel();
|
||||||
|
int dx = e.key == Key::Left ? -1 : e.key == Key::Right ? 1 : 0, dy = e.key == Key::Up ? -1 : e.key == Key::Down ? 1 : 0;
|
||||||
|
changed = frame_.pan(dx, dy);
|
||||||
|
if (!changed && phase_ == Phase::Decoding) changed = true; // it was stopped: start it again
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case Key::Char:
|
||||||
|
if (e.ch != 'i' && e.ch != 'I') return false;
|
||||||
|
if (phase_ == Phase::Shown) say(describe());
|
||||||
|
return true;
|
||||||
|
default: return false;
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
phase_ = Phase::Wanted;
|
||||||
|
note_.clear();
|
||||||
|
noteDrawn_ = false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The strip the note was written over goes back as it was: no decoding for that.
|
||||||
|
void ImagePane::hideNote(Canvas* c) {
|
||||||
|
if (noteDrawn_ && c && phase_ == Phase::Shown && under_.size() == static_cast<size_t>(c->width()) * kNoteHeight) {
|
||||||
|
const auto& area = theme::kContent;
|
||||||
|
uint8_t* screen = static_cast<uint8_t*>(c->getBuffer());
|
||||||
|
std::memcpy(screen + (area.y + area.h - kNoteHeight) * c->width(), under_.data(), under_.size());
|
||||||
|
}
|
||||||
|
noteDrawn_ = false;
|
||||||
|
note_.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ImagePane::update(uint32_t) {
|
||||||
|
if (path_.empty()) return false;
|
||||||
|
uint32_t now = millis();
|
||||||
|
if (phase_ == Phase::Wanted) return true;
|
||||||
|
if (phase_ == Phase::Decoding) {
|
||||||
|
if (job_ && job_->done) return true;
|
||||||
|
if (now - pushedMs_ < kPushMs) return false;
|
||||||
|
pushedMs_ = now;
|
||||||
|
return true; // what has arrived so far
|
||||||
|
}
|
||||||
|
if (!note_.empty() && now - noteMs_ >= kNoteMs) {
|
||||||
|
hideNote(canvas_);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return !note_.empty() && !noteDrawn_;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ImagePane::start(Canvas& c) {
|
||||||
|
cancel();
|
||||||
|
job_ = std::make_shared<Job>();
|
||||||
|
job_->path = path_;
|
||||||
|
job_->info = info_;
|
||||||
|
job_->frame = frame_;
|
||||||
|
job_->map = frame_.map();
|
||||||
|
job_->size = size_;
|
||||||
|
job_->shotAt = shotAt_;
|
||||||
|
job_->screen = static_cast<uint8_t*>(c.getBuffer());
|
||||||
|
job_->stride = c.width();
|
||||||
|
auto job = job_;
|
||||||
|
storage_.runJob([job]() { job->run(); });
|
||||||
|
phase_ = Phase::Decoding;
|
||||||
|
pushedMs_ = millis();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ImagePane::draw(Canvas& c) {
|
||||||
|
if (path_.empty()) return;
|
||||||
|
canvas_ = &c;
|
||||||
|
const auto& area = theme::kContent;
|
||||||
|
if (phase_ == Phase::Wanted) {
|
||||||
|
c.fillRect(area.x, area.y, area.w, area.h, theme::kBackground);
|
||||||
|
noteDrawn_ = false;
|
||||||
|
problem_.clear();
|
||||||
|
start(c);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (phase_ == Phase::Decoding) {
|
||||||
|
if (!job_ || !job_->done) return; // the picture is arriving in the buffer by itself
|
||||||
|
problem_ = job_->why;
|
||||||
|
job_.reset();
|
||||||
|
phase_ = Phase::Shown;
|
||||||
|
if (!problem_.empty()) {
|
||||||
|
c.fillRect(area.x, area.y, area.w, area.h, theme::kBackground);
|
||||||
|
c.setFont(&fonts::body);
|
||||||
|
c.setTextColor(theme::kMuted);
|
||||||
|
c.setTextDatum(middle_center);
|
||||||
|
c.drawString(problem_.c_str(), area.x + area.w / 2, area.y + area.h / 2);
|
||||||
|
c.setTextDatum(top_left);
|
||||||
|
} else if (!told_) {
|
||||||
|
told_ = true;
|
||||||
|
note_ = describe();
|
||||||
|
noteMs_ = millis();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!note_.empty() && !noteDrawn_) {
|
||||||
|
int top = area.y + area.h - kNoteHeight;
|
||||||
|
uint8_t* screen = static_cast<uint8_t*>(c.getBuffer());
|
||||||
|
under_.assign(screen + top * c.width(), screen + (top + kNoteHeight) * c.width());
|
||||||
|
c.fillRect(area.x, top, area.w, kNoteHeight, theme::kBackground);
|
||||||
|
c.setFont(&fonts::small);
|
||||||
|
c.setTextColor(theme::kText);
|
||||||
|
c.setTextDatum(top_left);
|
||||||
|
c.drawString(note_.c_str(), area.x + 4, top + 2);
|
||||||
|
noteDrawn_ = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace roro
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "image_file.h"
|
||||||
|
#include "key_event.h"
|
||||||
|
#include "key_help.h"
|
||||||
|
#include "services/storage_service.h"
|
||||||
|
#include "ui/canvas.h"
|
||||||
|
|
||||||
|
namespace roro {
|
||||||
|
|
||||||
|
// A picture from the card, for the Storage App's viewer (issue #45, F1 Q233-Q242): PNG, JPEG, BMP
|
||||||
|
// and the first picture of a GIF (only JPEG needs a decoder that isn't ours). It is decoded once, straight into the screen's own buffer, and
|
||||||
|
// left there (App::retainsContent): there is no copy of it in memory. It is decoded again only
|
||||||
|
// when something else was drawn over it, or when it is zoomed or moved.
|
||||||
|
//
|
||||||
|
// The decoding runs on the storage task while the main loop goes on: a photograph takes seconds,
|
||||||
|
// and the picture appears as it comes. Anything that needs the screen back stops it first.
|
||||||
|
class ImagePane {
|
||||||
|
public:
|
||||||
|
explicit ImagePane(StorageService& storage) : storage_(storage) {}
|
||||||
|
|
||||||
|
std::string open(const std::string& path, uint32_t size); // "" or why it can't be shown
|
||||||
|
void close();
|
||||||
|
bool onKey(const KeyEvent& e); // true: the key was the picture's
|
||||||
|
void help(std::vector<KeyHelp>& out) const;
|
||||||
|
bool update(uint32_t nowMs); // true: draw again
|
||||||
|
void draw(Canvas& c);
|
||||||
|
void lost(); // the screen no longer holds it
|
||||||
|
void say(const std::string& text);
|
||||||
|
|
||||||
|
struct Job; // one decoding: shared with the storage task, which may outlive the view of it
|
||||||
|
|
||||||
|
private:
|
||||||
|
enum class Phase { Wanted, Decoding, Shown };
|
||||||
|
|
||||||
|
std::string describe() const;
|
||||||
|
void start(Canvas& c);
|
||||||
|
void cancel(); // returns once the storage task has let go of the screen
|
||||||
|
void hideNote(Canvas* c);
|
||||||
|
|
||||||
|
StorageService& storage_;
|
||||||
|
std::string path_, problem_, note_;
|
||||||
|
uint32_t size_ = 0, noteMs_ = 0, shotAt_ = 0, pushedMs_ = 0;
|
||||||
|
files::ImageInfo info_;
|
||||||
|
files::ImageFrame frame_;
|
||||||
|
Phase phase_ = Phase::Wanted;
|
||||||
|
std::shared_ptr<Job> job_;
|
||||||
|
Canvas* canvas_ = nullptr;
|
||||||
|
bool told_ = false, noteDrawn_ = false;
|
||||||
|
std::vector<uint8_t> under_; // what the note was written over
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace roro
|
||||||
@@ -52,6 +52,7 @@ class SettingsApp : public App {
|
|||||||
void draw(Canvas& c) override;
|
void draw(Canvas& c) override;
|
||||||
void help(std::vector<KeyHelp>& out) const override;
|
void help(std::vector<KeyHelp>& out) const override;
|
||||||
const char* helpTitle() const override;
|
const char* helpTitle() const override;
|
||||||
|
bool showsSecret() const override { return page_ == Page::Debug; } // the Debug Console's token
|
||||||
|
|
||||||
private:
|
private:
|
||||||
enum class Page { Menu, Text, Choice, About, Wifi, Firmware, Debug };
|
enum class Page { Menu, Text, Choice, About, Wifi, Firmware, Debug };
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ void ShellApp::onEnter() {
|
|||||||
scroll_ = 0;
|
scroll_ = 0;
|
||||||
confirm_.reset();
|
confirm_.reset();
|
||||||
// What Tab completes besides the firmware's own commands, written as `help` writes them.
|
// What Tab completes besides the firmware's own commands, written as `help` writes them.
|
||||||
ownHelp_ = "help | clear | quit | exit\nkey up|down|left|right|select|back|home|del|tab|space|help\n";
|
ownHelp_ = "help | clear | quit | exit\nkey up|down|left|right|select|back|home|del|tab|space|help|shot\n";
|
||||||
log_.clear();
|
log_.clear();
|
||||||
log_.add(open_ ? "The console's commands. `help` lists them." : "No memory for the Shell: leave an App, or stop IRC.");
|
log_.add(open_ ? "The console's commands. `help` lists them." : "No memory for the Shell: leave an App, or stop IRC.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ class StorageApp : public App {
|
|||||||
bool textEntryActive() const override { return view_ == View::Name || view_ == View::Editor; }
|
bool textEntryActive() const override { return view_ == View::Name || view_ == View::Editor; }
|
||||||
void update(uint32_t nowMs) override;
|
void update(uint32_t nowMs) override;
|
||||||
void draw(Canvas& c) override;
|
void draw(Canvas& c) override;
|
||||||
|
bool retainsContent() const override { return view_ == View::Viewer && viewer_.retains(); }
|
||||||
|
void contentLost() override { viewer_.lost(); }
|
||||||
void help(std::vector<KeyHelp>& out) const override;
|
void help(std::vector<KeyHelp>& out) const override;
|
||||||
const char* helpTitle() const override;
|
const char* helpTitle() const override;
|
||||||
|
|
||||||
|
|||||||
+24
-4
@@ -656,7 +656,7 @@ static const char* const kHelp =
|
|||||||
"gnss status | gnss restart | gnss track start|stop | gnss nmea on|off | gnss send <sentence without $ and checksum>\n"
|
"gnss status | gnss restart | gnss track start|stop | gnss nmea on|off | gnss send <sentence without $ and checksum>\n"
|
||||||
"crash the last crash: firmware, reason, task, backtrace\n"
|
"crash the last crash: firmware, reason, task, backtrace\n"
|
||||||
"coredump erase forget the core dump in flash\n"
|
"coredump erase forget the core dump in flash\n"
|
||||||
"key <name|char> press a key: up down left right select back home del tab space help, or one character; ctrl- alt- shift- before it (key ctrl-down)\n"
|
"key <name|char> press a key: up down left right select back home del tab space help shot, or one character; ctrl- alt- shift- before it (key ctrl-down)\n"
|
||||||
"wifi status | wifi add <ssid><TAB><password>\n"
|
"wifi status | wifi add <ssid><TAB><password>\n"
|
||||||
"wifi ip <ssid> dhcp | wifi ip <ssid> <address>/<prefix> [gateway] a Saved Network's IP setting\n"
|
"wifi ip <ssid> dhcp | wifi ip <ssid> <address>/<prefix> [gateway] a Saved Network's IP setting\n"
|
||||||
"wifi dns <a> [b] | wifi dns always on|off | wifi ntp <a> [b] DNS and NTP servers\n"
|
"wifi dns <a> [b] | wifi dns always on|off | wifi ntp <a> [b] DNS and NTP servers\n"
|
||||||
@@ -725,6 +725,26 @@ static void saveScreenshot() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fn+p (issue #83): the screen as it is, dialog, help panel or Toast included. Not the page that
|
||||||
|
// shows the Debug Console's token: a picture of it is a copy of the token in a file.
|
||||||
|
static void screenshotKey() {
|
||||||
|
const char* why = nullptr;
|
||||||
|
if (apps->foreground().showsSecret()) why = "No screenshot here: it shows the token";
|
||||||
|
else if (!storageService || !storageService->state().present) why = "No SD card for the screenshot";
|
||||||
|
if (why) {
|
||||||
|
bus.publish(Event::withText(EventType::Notification, why, static_cast<int32_t>(NotificationLevel::Warning)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
shotFrom = Console::Origin::System;
|
||||||
|
saveScreenshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every key goes through here, from the keyboard or from a console's `key`.
|
||||||
|
static void handleKey(const KeyEvent& e) {
|
||||||
|
if (e.key == Key::Screenshot) return screenshotKey();
|
||||||
|
apps->handleKey(e);
|
||||||
|
}
|
||||||
|
|
||||||
static void screenshotStep() {
|
static void screenshotStep() {
|
||||||
if (shotPending && static_cast<int32_t>(millis() - shotDueMs) >= 0) {
|
if (shotPending && static_cast<int32_t>(millis() - shotDueMs) >= 0) {
|
||||||
shotPending = false;
|
shotPending = false;
|
||||||
@@ -1039,14 +1059,14 @@ static void runCommand(String line, bool fromSerial = false) {
|
|||||||
}
|
}
|
||||||
Key key = k == "up" ? Key::Up : k == "down" ? Key::Down : k == "left" ? Key::Left
|
Key key = k == "up" ? Key::Up : k == "down" ? Key::Down : k == "left" ? Key::Left
|
||||||
: k == "right" ? Key::Right : k == "back" ? Key::Back : k == "home" ? Key::Home
|
: k == "right" ? Key::Right : k == "back" ? Key::Back : k == "home" ? Key::Home
|
||||||
: k == "del" ? Key::Delete : k == "tab" ? Key::Tab : k == "help" ? Key::Help : Key::Select;
|
: k == "del" ? Key::Delete : k == "tab" ? Key::Tab : k == "help" ? Key::Help : k == "shot" ? Key::Screenshot : Key::Select;
|
||||||
KeyEvent ev = k == "space" ? KeyEvent::character(' ')
|
KeyEvent ev = k == "space" ? KeyEvent::character(' ')
|
||||||
: k.length() == 1 && k[0] > ' ' ? KeyEvent::character((unsigned char)k[0])
|
: k.length() == 1 && k[0] > ' ' ? KeyEvent::character((unsigned char)k[0])
|
||||||
: KeyEvent::of(key);
|
: KeyEvent::of(key);
|
||||||
ev.ctrl = ctrl;
|
ev.ctrl = ctrl;
|
||||||
ev.alt = alt;
|
ev.alt = alt;
|
||||||
ev.shift = shift;
|
ev.shift = shift;
|
||||||
if (!power->onKey(millis())) apps->handleKey(ev);
|
if (!power->onKey(millis())) handleKey(ev);
|
||||||
}
|
}
|
||||||
if (line.startsWith("wifi add ")) { // wifi add <ssid>\t<password>: credentials never touch the repo
|
if (line.startsWith("wifi add ")) { // wifi add <ssid>\t<password>: credentials never touch the repo
|
||||||
int tab = line.indexOf('\t');
|
int tab = line.indexOf('\t');
|
||||||
@@ -1385,7 +1405,7 @@ static void loopPass() {
|
|||||||
keyMapper.setTextEntry(apps->foreground().textEntryActive());
|
keyMapper.setTextEntry(apps->foreground().textEntryActive());
|
||||||
auto events = keyMapper.update(readKeys()); // always, so held keys are tracked
|
auto events = keyMapper.update(readKeys()); // always, so held keys are tracked
|
||||||
if (!swallow)
|
if (!swallow)
|
||||||
for (auto& e : events) apps->handleKey(e);
|
for (auto& e : events) handleKey(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Issue #20: the GNSS receiver costs the LoRa radio 8 dB while it runs. If the user chose so,
|
// Issue #20: the GNSS receiver costs the LoRa radio 8 dB while it runs. If the user chose so,
|
||||||
|
|||||||
+16
-2
@@ -13,10 +13,23 @@ bool Screen::begin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void Screen::render(AppManager& apps, const StatusInfo& status, const Toast* toast) {
|
void Screen::render(AppManager& apps, const StatusInfo& status, const Toast* toast) {
|
||||||
canvas_.fillSprite(theme::kBackground);
|
|
||||||
const auto& area = theme::kContent;
|
const auto& area = theme::kContent;
|
||||||
|
App& app = apps.foreground();
|
||||||
|
// An App that keeps what it drew: only the Status Bar is cleared, unless the App has changed
|
||||||
|
// or a Toast or the help panel, drawn over it, has just gone.
|
||||||
|
bool overlay = apps.help().isOpen() || toast, retains = app.retainsContent();
|
||||||
|
if (overlayWas_ && !overlay) lost_ = true;
|
||||||
|
if (retains && &app == lastApp_ && !lost_) {
|
||||||
|
canvas_.fillRect(0, 0, theme::kWidth, area.y, theme::kBackground);
|
||||||
|
} else {
|
||||||
|
canvas_.fillSprite(theme::kBackground);
|
||||||
|
if (retains) app.contentLost();
|
||||||
|
}
|
||||||
|
lastApp_ = &app;
|
||||||
|
lost_ = false;
|
||||||
|
overlayWas_ = overlay && retains;
|
||||||
canvas_.setClipRect(area.x, area.y, area.w, area.h);
|
canvas_.setClipRect(area.x, area.y, area.w, area.h);
|
||||||
apps.foreground().draw(canvas_);
|
app.draw(canvas_);
|
||||||
if (apps.help().isOpen()) widgets::help(canvas_, apps.help()); // over the App, under the Status Bar
|
if (apps.help().isOpen()) widgets::help(canvas_, apps.help()); // over the App, under the Status Bar
|
||||||
canvas_.clearClipRect();
|
canvas_.clearClipRect();
|
||||||
widgets::statusBar(canvas_, status);
|
widgets::statusBar(canvas_, status);
|
||||||
@@ -25,6 +38,7 @@ void Screen::render(AppManager& apps, const StatusInfo& status, const Toast* toa
|
|||||||
}
|
}
|
||||||
|
|
||||||
void Screen::renderUpdate(const std::string& title, const std::string& detail, int percent) {
|
void Screen::renderUpdate(const std::string& title, const std::string& detail, int percent) {
|
||||||
|
lost_ = true;
|
||||||
canvas_.fillSprite(theme::kBackground);
|
canvas_.fillSprite(theme::kBackground);
|
||||||
canvas_.setTextDatum(top_center);
|
canvas_.setTextDatum(top_center);
|
||||||
canvas_.setFont(&fonts::bold);
|
canvas_.setFont(&fonts::bold);
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ class Screen {
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
Canvas canvas_;
|
Canvas canvas_;
|
||||||
|
const App* lastApp_ = nullptr; // for an App that keeps what it drew (App::retainsContent)
|
||||||
|
bool lost_ = true, overlayWas_ = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace roro
|
} // namespace roro
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,293 @@
|
|||||||
|
#include <unity.h>
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "image_file.h"
|
||||||
|
#include "images.h"
|
||||||
|
#include "png_rgb332.h"
|
||||||
|
|
||||||
|
using namespace roro::files;
|
||||||
|
|
||||||
|
void setUp() {}
|
||||||
|
void tearDown() {}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
ImageRead from(const std::string& bytes) {
|
||||||
|
return [&bytes](uint32_t at, uint8_t* into, size_t len) -> size_t {
|
||||||
|
if (at >= bytes.size()) return 0;
|
||||||
|
size_t n = std::min(len, bytes.size() - at);
|
||||||
|
std::memcpy(into, bytes.data() + at, n);
|
||||||
|
return n;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
template <size_t N>
|
||||||
|
std::string str(const uint8_t (&a)[N]) { return std::string(reinterpret_cast<const char*>(a), N); }
|
||||||
|
|
||||||
|
// The palette and the two patterns the test files were drawn with.
|
||||||
|
void colour(int i, uint8_t* rgb) {
|
||||||
|
rgb[0] = static_cast<uint8_t>((i * 7) % 256);
|
||||||
|
rgb[1] = static_cast<uint8_t>((255 - i * 3) % 256);
|
||||||
|
rgb[2] = static_cast<uint8_t>((i * 13 + 40) % 256);
|
||||||
|
}
|
||||||
|
int small(int x, int y) { return (x * 3 + y * 5) % 7; }
|
||||||
|
int big(int x, int y) { return (x * x + y * y * 3 + x * y) & 0xFF; }
|
||||||
|
|
||||||
|
struct Picture {
|
||||||
|
int w, h;
|
||||||
|
std::vector<int> rgb; // -1: never drawn
|
||||||
|
Picture(int width, int height) : w(width), h(height), rgb(static_cast<size_t>(width * height), -1) {}
|
||||||
|
ImagePixels sink() {
|
||||||
|
return [this](int x, int y, int count, const uint8_t* p) {
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
TEST_ASSERT_TRUE(x + i >= 0 && x + i < w && y >= 0 && y < h);
|
||||||
|
rgb[static_cast<size_t>(y * w + x + i)] = (p[i * 3] << 16) | (p[i * 3 + 1] << 8) | p[i * 3 + 2];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void checkGif(const std::string& file, int w, int h, int (*index)(int, int), int transparent = -1) {
|
||||||
|
Picture p(w, h);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("", readGif(from(file), static_cast<uint32_t>(file.size()), p.sink()).c_str());
|
||||||
|
for (int y = 0; y < h; y++)
|
||||||
|
for (int x = 0; x < w; x++) {
|
||||||
|
uint8_t c[3];
|
||||||
|
colour(index(x, y), c);
|
||||||
|
int want = index(x, y) == transparent ? -1 : (c[0] << 16) | (c[1] << 8) | c[2];
|
||||||
|
char msg[40];
|
||||||
|
std::snprintf(msg, sizeof msg, "at %d,%d", x, y);
|
||||||
|
TEST_ASSERT_EQUAL_HEX32_MESSAGE(want, p.rgb[static_cast<size_t>(y * w + x)], msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void test_kinds() {
|
||||||
|
TEST_ASSERT_TRUE(imageKindOfName("Photo.JPG") == ImageKind::Jpeg);
|
||||||
|
TEST_ASSERT_TRUE(imageKindOfName("a.jpeg") == ImageKind::Jpeg);
|
||||||
|
TEST_ASSERT_TRUE(imageKindOfName("20261007-104357.png") == ImageKind::Png);
|
||||||
|
TEST_ASSERT_TRUE(imageKindOfName("x.bmp") == ImageKind::Bmp);
|
||||||
|
TEST_ASSERT_TRUE(imageKindOfName("x.gif") == ImageKind::Gif);
|
||||||
|
TEST_ASSERT_TRUE(imageKindOfName("notes.txt") == ImageKind::None);
|
||||||
|
const uint8_t jpeg[] = {0xFF, 0xD8, 0xFF, 0xE0};
|
||||||
|
TEST_ASSERT_TRUE(imageKindOfBytes(jpeg, 4) == ImageKind::Jpeg);
|
||||||
|
TEST_ASSERT_TRUE(imageKindOfBytes(kGifSmall, 6) == ImageKind::Gif);
|
||||||
|
TEST_ASSERT_TRUE(imageKindOfBytes(kBmp24, 8) == ImageKind::Bmp);
|
||||||
|
TEST_ASSERT_TRUE(imageKindOfBytes(reinterpret_cast<const uint8_t*>("Hello"), 5) == ImageKind::None);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_sizes() {
|
||||||
|
ImageInfo info;
|
||||||
|
std::string gif = str(kGifBig), bmp = str(kBmp24);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("", imageInfo(from(gif), static_cast<uint32_t>(gif.size()), info).c_str());
|
||||||
|
TEST_ASSERT_TRUE(info.kind == ImageKind::Gif && info.width == 96 && info.height == 64);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("", imageInfo(from(bmp), static_cast<uint32_t>(bmp.size()), info).c_str());
|
||||||
|
TEST_ASSERT_TRUE(info.kind == ImageKind::Bmp && info.width == 13 && info.height == 7);
|
||||||
|
|
||||||
|
// A JPEG: start, an application segment, a table, then the frame header with the size.
|
||||||
|
std::string jpeg("\xFF\xD8", 2);
|
||||||
|
jpeg += std::string("\xFF\xE1\x00\x08" "Exif\0\0", 10);
|
||||||
|
jpeg += std::string("\xFF\xDB\x00\x04\x00\x00", 6);
|
||||||
|
std::string frame("\xFF\xC0\x00\x11\x08\x0B\xB8\x0F\xA0\x03", 10); // 3000 high, 4000 wide
|
||||||
|
std::string baseline = jpeg + frame + std::string(20, '\0');
|
||||||
|
TEST_ASSERT_EQUAL_STRING("", imageInfo(from(baseline), static_cast<uint32_t>(baseline.size()), info).c_str());
|
||||||
|
TEST_ASSERT_TRUE(info.kind == ImageKind::Jpeg && info.width == 4000 && info.height == 3000);
|
||||||
|
frame[1] = '\xC2';
|
||||||
|
std::string progressive = jpeg + frame + std::string(20, '\0');
|
||||||
|
TEST_ASSERT_EQUAL_STRING("A progressive JPEG can't be shown", imageInfo(from(progressive), static_cast<uint32_t>(progressive.size()), info).c_str());
|
||||||
|
std::string cut = jpeg;
|
||||||
|
TEST_ASSERT_EQUAL_STRING("This JPEG is damaged", imageInfo(from(cut), static_cast<uint32_t>(cut.size()), info).c_str());
|
||||||
|
|
||||||
|
std::string text = "Just some words, not a picture at all.";
|
||||||
|
TEST_ASSERT_EQUAL_STRING("Not a picture this can show", imageInfo(from(text), static_cast<uint32_t>(text.size()), info).c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_a_screenshot_is_read_without_decoding() {
|
||||||
|
const int w = 240, h = 135;
|
||||||
|
std::string file;
|
||||||
|
roro::png::Rgb332Writer writer(w, h, [&](const uint8_t* d, size_t n) {
|
||||||
|
file.append(reinterpret_cast<const char*>(d), n);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
TEST_ASSERT_TRUE(writer.begin());
|
||||||
|
std::vector<uint8_t> row(w);
|
||||||
|
for (int y = 0; y < h; y++) {
|
||||||
|
for (int x = 0; x < w; x++) row[static_cast<size_t>(x)] = static_cast<uint8_t>(x * 3 + y * 7);
|
||||||
|
TEST_ASSERT_TRUE(writer.row(row.data()));
|
||||||
|
}
|
||||||
|
TEST_ASSERT_TRUE(writer.end());
|
||||||
|
ImageInfo info;
|
||||||
|
TEST_ASSERT_EQUAL_STRING("", imageInfo(from(file), static_cast<uint32_t>(file.size()), info).c_str());
|
||||||
|
TEST_ASSERT_TRUE(info.kind == ImageKind::Png && info.width == w && info.height == h);
|
||||||
|
uint32_t at = screenshotPixelsAt(from(file), static_cast<uint32_t>(file.size()), w, h);
|
||||||
|
TEST_ASSERT_TRUE(at > 0);
|
||||||
|
for (int y : {0, 1, 77, 134})
|
||||||
|
for (int x : {0, 5, 239})
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(static_cast<uint8_t>(x * 3 + y * 7), static_cast<uint8_t>(file[at + static_cast<uint32_t>(y * (w + 1) + x)]));
|
||||||
|
// Another PNG isn't taken for one: a byte more, a compressed block.
|
||||||
|
std::string longer = file + "x";
|
||||||
|
TEST_ASSERT_EQUAL_UINT32(0, screenshotPixelsAt(from(longer), static_cast<uint32_t>(longer.size()), w, h));
|
||||||
|
std::string other = file;
|
||||||
|
other[8 + 25 + 780 + 10] = 0x05;
|
||||||
|
TEST_ASSERT_EQUAL_UINT32(0, screenshotPixelsAt(from(other), static_cast<uint32_t>(other.size()), w, h));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_dithering_leaves_the_screens_own_colours_alone() {
|
||||||
|
for (int v = 0; v < 256; v++) {
|
||||||
|
uint8_t r = static_cast<uint8_t>((v >> 5) * 255 / 7), g = static_cast<uint8_t>(((v >> 2) & 7) * 255 / 7), b = static_cast<uint8_t>((v & 3) * 85);
|
||||||
|
for (int y = 0; y < 4; y++)
|
||||||
|
for (int x = 0; x < 4; x++) TEST_ASSERT_EQUAL_UINT8(v, rgb332Dithered(r, g, b, x, y));
|
||||||
|
}
|
||||||
|
// A grey between two levels comes out as a mix of both, in proportion.
|
||||||
|
int high = 0;
|
||||||
|
for (int y = 0; y < 4; y++)
|
||||||
|
for (int x = 0; x < 4; x++) {
|
||||||
|
int red = rgb332Dithered(54, 0, 0, x, y) >> 5; // half way from 36 to 72
|
||||||
|
TEST_ASSERT_TRUE(red == 1 || red == 2);
|
||||||
|
high += red == 2;
|
||||||
|
}
|
||||||
|
TEST_ASSERT_TRUE(high >= 6 && high <= 10);
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(0xFF, rgb332Dithered(255, 255, 255, 1, 2));
|
||||||
|
TEST_ASSERT_EQUAL_UINT8(0x00, rgb332Dithered(0, 0, 0, 3, 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_a_small_picture_sits_in_the_middle() {
|
||||||
|
ImageFrame f(100, 50, 0, 12, 240, 123);
|
||||||
|
TEST_ASSERT_FALSE(f.bigger());
|
||||||
|
TEST_ASSERT_EQUAL_INT(100, f.percent());
|
||||||
|
ImageMap m = f.map();
|
||||||
|
int tx, ty;
|
||||||
|
TEST_ASSERT_TRUE(m.at(0, 0, tx, ty));
|
||||||
|
TEST_ASSERT_EQUAL_INT(70, tx);
|
||||||
|
TEST_ASSERT_EQUAL_INT(12 + 36, ty);
|
||||||
|
TEST_ASSERT_TRUE(m.at(99, 49, tx, ty));
|
||||||
|
TEST_ASSERT_EQUAL_INT(169, tx);
|
||||||
|
TEST_ASSERT_FALSE(m.at(-1, 0, tx, ty));
|
||||||
|
f.toggle(); // nothing to zoom
|
||||||
|
TEST_ASSERT_FALSE(f.actual());
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, f.jpegShrink());
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_a_big_picture_is_shrunk_to_fit_each_screen_pixel_once() {
|
||||||
|
ImageFrame f(4000, 3000, 0, 12, 240, 123);
|
||||||
|
TEST_ASSERT_TRUE(f.bigger());
|
||||||
|
TEST_ASSERT_EQUAL_INT(4, f.percent()); // 123 / 3000
|
||||||
|
ImageMap m = f.map();
|
||||||
|
std::vector<int> hits(240 * 135, 0);
|
||||||
|
int rows = 0;
|
||||||
|
for (int y = 0; y < 3000; y++) {
|
||||||
|
if (!m.rowUsed(y)) continue;
|
||||||
|
rows++;
|
||||||
|
for (int x = 0; x < 4000; x++) {
|
||||||
|
int tx, ty;
|
||||||
|
if (m.at(x, y, tx, ty)) hits[static_cast<size_t>(ty * 240 + tx)]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TEST_ASSERT_EQUAL_INT(123, rows);
|
||||||
|
int drawn = 0;
|
||||||
|
for (int ty = 0; ty < 135; ty++)
|
||||||
|
for (int tx = 0; tx < 240; tx++) {
|
||||||
|
int n = hits[static_cast<size_t>(ty * 240 + tx)];
|
||||||
|
TEST_ASSERT_TRUE(n <= 1);
|
||||||
|
drawn += n;
|
||||||
|
if (n) TEST_ASSERT_TRUE(ty >= 12 && tx >= 38 && tx < 202); // 164 wide, centred
|
||||||
|
}
|
||||||
|
TEST_ASSERT_EQUAL_INT(164 * 123, drawn);
|
||||||
|
|
||||||
|
// A JPEG decoder may halve it three times first; the map then takes those pixels.
|
||||||
|
TEST_ASSERT_EQUAL_INT(3, f.jpegShrink());
|
||||||
|
ImageMap j = f.map(3);
|
||||||
|
int tx, ty, again = 0;
|
||||||
|
for (int y = 0; y < 375; y++)
|
||||||
|
for (int x = 0; x < 500; x++) again += j.at(x, y, tx, ty);
|
||||||
|
TEST_ASSERT_EQUAL_INT(164 * 123, again);
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, ImageFrame(300, 200, 0, 12, 240, 123).jpegShrink()); // not by half: it would be too small
|
||||||
|
TEST_ASSERT_EQUAL_INT(1, ImageFrame(480, 246, 0, 12, 240, 123).jpegShrink());
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_at_its_own_size_it_moves_half_a_screen_at_a_time() {
|
||||||
|
ImageFrame f(800, 600, 0, 12, 240, 123);
|
||||||
|
f.toggle();
|
||||||
|
TEST_ASSERT_TRUE(f.actual());
|
||||||
|
TEST_ASSERT_EQUAL_INT(100, f.percent());
|
||||||
|
ImageMap m = f.map();
|
||||||
|
int tx, ty;
|
||||||
|
TEST_ASSERT_TRUE(m.at(280, 238, tx, ty)); // the middle first
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, tx);
|
||||||
|
TEST_ASSERT_EQUAL_INT(12, ty);
|
||||||
|
TEST_ASSERT_FALSE(m.at(279, 238, tx, ty));
|
||||||
|
TEST_ASSERT_FALSE(m.below(238 + 122)); // the last row on screen
|
||||||
|
TEST_ASSERT_TRUE(m.below(238 + 123)); // from here on a decoder can stop
|
||||||
|
TEST_ASSERT_TRUE(f.pan(1, 0));
|
||||||
|
TEST_ASSERT_TRUE(f.map().at(400, 238, tx, ty));
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, tx);
|
||||||
|
for (int i = 0; i < 10; i++) f.pan(1, 1);
|
||||||
|
TEST_ASSERT_FALSE(f.pan(1, 0)); // the corner: no further
|
||||||
|
TEST_ASSERT_TRUE(f.map().at(799, 599, tx, ty));
|
||||||
|
TEST_ASSERT_EQUAL_INT(239, tx);
|
||||||
|
TEST_ASSERT_EQUAL_INT(12 + 122, ty);
|
||||||
|
f.toggle();
|
||||||
|
TEST_ASSERT_FALSE(f.actual());
|
||||||
|
TEST_ASSERT_FALSE(f.pan(1, 0)); // fitted: nothing to move
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_gif() {
|
||||||
|
checkGif(str(kGifSmall), 16, 12, small);
|
||||||
|
checkGif(str(kGifSmallInterlaced), 16, 12, small);
|
||||||
|
checkGif(str(kGifBig), 96, 64, big); // long enough for the codes to grow to 12 bits and start over
|
||||||
|
checkGif(str(kGifBigInterlaced), 96, 64, big);
|
||||||
|
checkGif(str(kGifTransparent), 16, 12, small, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_gif_damaged() {
|
||||||
|
Picture p(96, 64);
|
||||||
|
std::string cut = str(kGifBig).substr(0, 3000);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("This GIF is cut short", readGif(from(cut), static_cast<uint32_t>(cut.size()), p.sink()).c_str());
|
||||||
|
std::string head = str(kGifBig).substr(0, 10);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("This GIF is damaged", readGif(from(head), static_cast<uint32_t>(head.size()), p.sink()).c_str());
|
||||||
|
// Any byte changed: an answer, never a crash or a pixel outside the picture (the sink checks).
|
||||||
|
std::string file = str(kGifBig);
|
||||||
|
for (size_t i = 0; i < file.size(); i += 37) {
|
||||||
|
std::string bad = file;
|
||||||
|
bad[i] = static_cast<char>(bad[i] ^ 0x5A);
|
||||||
|
Picture q(96, 64);
|
||||||
|
readGif(from(bad), static_cast<uint32_t>(bad.size()), q.sink());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_bmp() {
|
||||||
|
std::string file = str(kBmp24);
|
||||||
|
Picture p(13, 7);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("", readBmp(from(file), static_cast<uint32_t>(file.size()), p.sink()).c_str());
|
||||||
|
for (int y = 0; y < 7; y++)
|
||||||
|
for (int x = 0; x < 13; x++)
|
||||||
|
TEST_ASSERT_EQUAL_HEX32((((x * 9) % 256) << 16) | (((y * 17) % 256) << 8) | ((x + y) % 256), p.rgb[static_cast<size_t>(y * 13 + x)]);
|
||||||
|
std::string file8 = str(kBmp8);
|
||||||
|
Picture q(13, 7);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("", readBmp(from(file8), static_cast<uint32_t>(file8.size()), q.sink()).c_str());
|
||||||
|
for (int i = 0; i < 13 * 7; i++)
|
||||||
|
TEST_ASSERT_EQUAL_HEX32((kBmp8Pixels[i * 3] << 16) | (kBmp8Pixels[i * 3 + 1] << 8) | kBmp8Pixels[i * 3 + 2], q.rgb[static_cast<size_t>(i)]);
|
||||||
|
// Only the rows asked for are read.
|
||||||
|
Picture r(13, 7);
|
||||||
|
readBmp(from(file), static_cast<uint32_t>(file.size()), r.sink(), [](int y) { return y == 2; });
|
||||||
|
TEST_ASSERT_EQUAL_INT(-1, r.rgb[0]);
|
||||||
|
TEST_ASSERT_TRUE(r.rgb[2 * 13] >= 0);
|
||||||
|
std::string cut = file.substr(0, 200);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("This BMP is cut short", readBmp(from(cut), static_cast<uint32_t>(cut.size()), p.sink()).c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
UNITY_BEGIN();
|
||||||
|
RUN_TEST(test_kinds);
|
||||||
|
RUN_TEST(test_sizes);
|
||||||
|
RUN_TEST(test_a_screenshot_is_read_without_decoding);
|
||||||
|
RUN_TEST(test_dithering_leaves_the_screens_own_colours_alone);
|
||||||
|
RUN_TEST(test_a_small_picture_sits_in_the_middle);
|
||||||
|
RUN_TEST(test_a_big_picture_is_shrunk_to_fit_each_screen_pixel_once);
|
||||||
|
RUN_TEST(test_at_its_own_size_it_moves_half_a_screen_at_a_time);
|
||||||
|
RUN_TEST(test_gif);
|
||||||
|
RUN_TEST(test_gif_damaged);
|
||||||
|
RUN_TEST(test_bmp);
|
||||||
|
return UNITY_END();
|
||||||
|
}
|
||||||
@@ -231,6 +231,22 @@ void test_fn_h_is_help_in_both_modes() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Issue #83: Fn+p everywhere.
|
||||||
|
void test_fn_p_is_a_screenshot_in_both_modes() {
|
||||||
|
for (bool typing : {true, false}) {
|
||||||
|
KeyMapper m;
|
||||||
|
m.setTextEntry(typing);
|
||||||
|
auto ev = press(m, withFn({'p'}));
|
||||||
|
TEST_ASSERT_EQUAL(1, ev.size());
|
||||||
|
TEST_ASSERT_EQUAL(static_cast<int>(Key::Screenshot), static_cast<int>(ev[0].key));
|
||||||
|
release(m);
|
||||||
|
ev = press(m, chars({'p'}));
|
||||||
|
TEST_ASSERT_EQUAL(1, ev.size());
|
||||||
|
TEST_ASSERT_EQUAL(static_cast<int>(Key::Char), static_cast<int>(ev[0].key));
|
||||||
|
TEST_ASSERT_EQUAL('p', ev[0].ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void test_plain_h_still_types() {
|
void test_plain_h_still_types() {
|
||||||
for (bool typing : {true, false}) {
|
for (bool typing : {true, false}) {
|
||||||
KeyMapper m;
|
KeyMapper m;
|
||||||
@@ -278,6 +294,7 @@ int main() {
|
|||||||
RUN_TEST(test_other_characters_still_type_when_not_typing);
|
RUN_TEST(test_other_characters_still_type_when_not_typing);
|
||||||
RUN_TEST(test_shifted_arrow_keys_type_their_symbols_when_not_typing);
|
RUN_TEST(test_shifted_arrow_keys_type_their_symbols_when_not_typing);
|
||||||
RUN_TEST(test_fn_h_is_help_in_both_modes);
|
RUN_TEST(test_fn_h_is_help_in_both_modes);
|
||||||
|
RUN_TEST(test_fn_p_is_a_screenshot_in_both_modes);
|
||||||
RUN_TEST(test_plain_h_still_types);
|
RUN_TEST(test_plain_h_still_types);
|
||||||
RUN_TEST(test_question_mark_is_help_only_when_not_typing);
|
RUN_TEST(test_question_mark_is_help_only_when_not_typing);
|
||||||
return UNITY_END();
|
return UNITY_END();
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,191 @@
|
|||||||
|
#include <unity.h>
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "png_reader.h"
|
||||||
|
#include "png_rgb332.h"
|
||||||
|
#include "pngs.h"
|
||||||
|
|
||||||
|
using namespace roro::files;
|
||||||
|
|
||||||
|
void setUp() {}
|
||||||
|
void tearDown() {}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
ImageRead from(const std::string& bytes) {
|
||||||
|
return [&bytes](uint32_t at, uint8_t* into, size_t len) -> size_t {
|
||||||
|
if (at >= bytes.size()) return 0;
|
||||||
|
size_t n = std::min(len, bytes.size() - at);
|
||||||
|
std::memcpy(into, bytes.data() + at, n);
|
||||||
|
return n;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
template <size_t N>
|
||||||
|
std::string str(const uint8_t (&a)[N]) { return std::string(reinterpret_cast<const char*>(a), N); }
|
||||||
|
|
||||||
|
struct Picture {
|
||||||
|
int w, h;
|
||||||
|
std::vector<int> rgb; // -1: never drawn
|
||||||
|
Picture(int width, int height) : w(width), h(height), rgb(static_cast<size_t>(width * height), -1) {}
|
||||||
|
ImagePixels sink() {
|
||||||
|
return [this](int x, int y, int count, const uint8_t* p) {
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
TEST_ASSERT_TRUE(x + i >= 0 && x + i < w && y >= 0 && y < h);
|
||||||
|
rgb[static_cast<size_t>(y * w + x + i)] = (p[i * 3] << 16) | (p[i * 3 + 1] << 8) | p[i * 3 + 2];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
int at(int x, int y) const { return rgb[static_cast<size_t>(y * w + x)]; }
|
||||||
|
};
|
||||||
|
|
||||||
|
Picture decode(const std::string& file, int w, int h) {
|
||||||
|
Picture p(w, h);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("", readPng(from(file), static_cast<uint32_t>(file.size()), p.sink()).c_str());
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
int colour(int x, int y) { return (((x * 9 + y) % 256) << 16) | (((y * 17) % 256) << 8) | ((x * x + y * y) % 256); }
|
||||||
|
int grey(int v) { return (v << 16) | (v << 8) | v; }
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void test_rgb() {
|
||||||
|
Picture p = decode(str(kPngRgb), 96, 64);
|
||||||
|
for (int y = 0; y < 64; y++)
|
||||||
|
for (int x = 0; x < 96; x++) TEST_ASSERT_EQUAL_HEX32(colour(x, y), p.at(x, y));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_a_noisy_picture_with_every_kind_of_row() {
|
||||||
|
Picture p = decode(str(kPngPhoto), 64, 48);
|
||||||
|
for (int i = 0; i < 64 * 48; i++)
|
||||||
|
TEST_ASSERT_EQUAL_HEX32((kPngPhotoPixels[i * 3] << 16) | (kPngPhotoPixels[i * 3 + 1] << 8) | kPngPhotoPixels[i * 3 + 2], p.rgb[static_cast<size_t>(i)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_stored_not_compressed() {
|
||||||
|
Picture p = decode(str(kPngStored), 16, 12);
|
||||||
|
for (int y = 0; y < 12; y++)
|
||||||
|
for (int x = 0; x < 16; x++) TEST_ASSERT_EQUAL_HEX32(colour(x, y), p.at(x, y));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_references_far_back() {
|
||||||
|
Picture p = decode(str(kPngFarBack), 120, 90);
|
||||||
|
uint32_t total = 0;
|
||||||
|
for (int v : p.rgb) {
|
||||||
|
TEST_ASSERT_TRUE(v >= 0);
|
||||||
|
total += static_cast<uint32_t>((v >> 16) + ((v >> 8) & 255) + (v & 255));
|
||||||
|
}
|
||||||
|
TEST_ASSERT_EQUAL_UINT32((kPngFarBackSum[0] << 24) | (kPngFarBackSum[1] << 16) | (kPngFarBackSum[2] << 8) | kPngFarBackSum[3], total);
|
||||||
|
for (int x = 0; x < 120; x++) TEST_ASSERT_EQUAL_HEX32(p.at(x, 0) + 1, p.at(x, 60)); // the same row, its blue one more
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_transparent_pixels_are_left_out() {
|
||||||
|
Picture p = decode(str(kPngRgba), 16, 12);
|
||||||
|
for (int y = 0; y < 12; y++)
|
||||||
|
for (int x = 0; x < 16; x++) TEST_ASSERT_EQUAL_HEX32((x + y) % 5 == 0 ? -1 : colour(x, y), p.at(x, y));
|
||||||
|
Picture g = decode(str(kPngGrayAlpha), 16, 12);
|
||||||
|
for (int y = 0; y < 12; y++)
|
||||||
|
for (int x = 0; x < 16; x++) TEST_ASSERT_EQUAL_HEX32(x % 2 ? grey((x * 16 + y * 3) % 256) : -1, g.at(x, y));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_palettes() {
|
||||||
|
auto entry = [](int i) { return (((i * 7) % 256) << 16) | (((255 - i * 3) % 256) << 8) | ((i * 13 + 40) % 256); };
|
||||||
|
Picture p = decode(str(kPngPalette), 16, 12);
|
||||||
|
for (int y = 0; y < 12; y++)
|
||||||
|
for (int x = 0; x < 16; x++) {
|
||||||
|
int i = (x * 3 + y * 5) % 7;
|
||||||
|
TEST_ASSERT_EQUAL_HEX32(i == 3 ? -1 : entry(i), p.at(x, y)); // index 3 is the transparent one
|
||||||
|
}
|
||||||
|
Picture q = decode(str(kPngPalette4), 17, 5); // four bits a pixel, a row that ends in half a byte
|
||||||
|
for (int y = 0; y < 5; y++)
|
||||||
|
for (int x = 0; x < 17; x++) TEST_ASSERT_EQUAL_HEX32(entry((x + y * 3) % 13), q.at(x, y));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_greys() {
|
||||||
|
Picture g = decode(str(kPngGray), 16, 12);
|
||||||
|
Picture one = decode(str(kPngGray1), 19, 7);
|
||||||
|
Picture deep = decode(str(kPngGray16), 16, 12);
|
||||||
|
for (int y = 0; y < 12; y++)
|
||||||
|
for (int x = 0; x < 16; x++) {
|
||||||
|
TEST_ASSERT_EQUAL_HEX32(grey((x * 16 + y * 3) % 256), g.at(x, y));
|
||||||
|
TEST_ASSERT_EQUAL_HEX32(grey(((x * 4000 + y * 300) % 65536) >> 8), deep.at(x, y));
|
||||||
|
}
|
||||||
|
for (int y = 0; y < 7; y++)
|
||||||
|
for (int x = 0; x < 19; x++) TEST_ASSERT_EQUAL_HEX32((x + y) % 2 ? 0xFFFFFF : 0, one.at(x, y));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_one_of_the_firmwares_own_screenshots() {
|
||||||
|
std::string file;
|
||||||
|
roro::png::Rgb332Writer writer(40, 9, [&](const uint8_t* d, size_t n) {
|
||||||
|
file.append(reinterpret_cast<const char*>(d), n);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
writer.begin();
|
||||||
|
std::vector<uint8_t> row(40);
|
||||||
|
for (int y = 0; y < 9; y++) {
|
||||||
|
for (int x = 0; x < 40; x++) row[static_cast<size_t>(x)] = static_cast<uint8_t>(x * 6 + y);
|
||||||
|
writer.row(row.data());
|
||||||
|
}
|
||||||
|
writer.end();
|
||||||
|
Picture p = decode(file, 40, 9);
|
||||||
|
for (int y = 0; y < 9; y++)
|
||||||
|
for (int x = 0; x < 40; x++) {
|
||||||
|
int v = (x * 6 + y) & 0xFF;
|
||||||
|
TEST_ASSERT_EQUAL_HEX32((((v >> 5) * 255 / 7) << 16) | ((((v >> 2) & 7) * 255 / 7) << 8) | ((v & 3) * 85), p.at(x, y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_rows_can_be_left_out_and_the_end_cut_off() {
|
||||||
|
std::string file = str(kPngRgb);
|
||||||
|
Picture p(96, 64);
|
||||||
|
int reads = 0;
|
||||||
|
ImageRead counted = [&](uint32_t at, uint8_t* into, size_t len) {
|
||||||
|
reads++;
|
||||||
|
return from(file)(at, into, len);
|
||||||
|
};
|
||||||
|
TEST_ASSERT_EQUAL_STRING("", readPng(counted, static_cast<uint32_t>(file.size()), p.sink(), [](int y) { return y % 2 == 0; }, [](int y) { return y >= 10; }).c_str());
|
||||||
|
TEST_ASSERT_EQUAL_HEX32(colour(5, 8), p.at(5, 8));
|
||||||
|
TEST_ASSERT_EQUAL_INT(-1, p.at(5, 9)); // left out
|
||||||
|
TEST_ASSERT_EQUAL_INT(-1, p.at(5, 10)); // and nothing from the tenth on
|
||||||
|
Picture all(96, 64);
|
||||||
|
int allReads = 0;
|
||||||
|
ImageRead countAll = [&](uint32_t at, uint8_t* into, size_t len) {
|
||||||
|
allReads++;
|
||||||
|
return from(file)(at, into, len);
|
||||||
|
};
|
||||||
|
readPng(countAll, static_cast<uint32_t>(file.size()), all.sink());
|
||||||
|
TEST_ASSERT_TRUE(reads < allReads / 2); // it stopped reading the file too
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_what_it_refuses() {
|
||||||
|
Picture p(96, 64);
|
||||||
|
std::string interlaced = str(kPngRgb);
|
||||||
|
interlaced[28] = 1;
|
||||||
|
TEST_ASSERT_EQUAL_STRING("An interlaced PNG can't be shown", readPng(from(interlaced), static_cast<uint32_t>(interlaced.size()), p.sink()).c_str());
|
||||||
|
std::string cut = str(kPngRgb).substr(0, 2000);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("This PNG is cut short", readPng(from(cut), static_cast<uint32_t>(cut.size()), p.sink()).c_str());
|
||||||
|
std::string notPng = "GIF89a and then some more bytes that are not a PNG";
|
||||||
|
TEST_ASSERT_EQUAL_STRING("This PNG is damaged", readPng(from(notPng), static_cast<uint32_t>(notPng.size()), p.sink()).c_str());
|
||||||
|
// Any byte changed: an answer, never a crash or a pixel outside the picture (the sink checks).
|
||||||
|
std::string file = str(kPngPhoto);
|
||||||
|
for (size_t i = 8; i < file.size(); i += 7) {
|
||||||
|
std::string bad = file;
|
||||||
|
bad[i] = static_cast<char>(bad[i] ^ 0xA5);
|
||||||
|
Picture q(64, 48);
|
||||||
|
readPng(from(bad), static_cast<uint32_t>(bad.size()), q.sink());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
UNITY_BEGIN();
|
||||||
|
RUN_TEST(test_rgb);
|
||||||
|
RUN_TEST(test_a_noisy_picture_with_every_kind_of_row);
|
||||||
|
RUN_TEST(test_stored_not_compressed);
|
||||||
|
RUN_TEST(test_references_far_back);
|
||||||
|
RUN_TEST(test_transparent_pixels_are_left_out);
|
||||||
|
RUN_TEST(test_palettes);
|
||||||
|
RUN_TEST(test_greys);
|
||||||
|
RUN_TEST(test_one_of_the_firmwares_own_screenshots);
|
||||||
|
RUN_TEST(test_rows_can_be_left_out_and_the_end_cut_off);
|
||||||
|
RUN_TEST(test_what_it_refuses);
|
||||||
|
return UNITY_END();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user