diff --git a/README.md b/README.md index 757a75c0..9b73efb4 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,23 @@ Convert your own TTF/OTF files into `.cpfont` files that load from the SD card. Conversion runs the firmware repo's `lib/EpdFont/scripts/fontconvert_sdcard.py` script unmodified, so output matches a local host build. +## Custom SD-card themes + +Downloadable themes are packaged in the tools repo under `../crosspoint-tools/public/themes//`. Each theme folder must contain a `theme.json`; optional assets such as generated BMP icons live beside it, usually under `icons/`. + +See [SD-card theme creation](./docs/theme-creation.md) for the full JSON format, device-specific overrides, icon generation, CrossInk extension fields, and packaging rules. + +After adding or changing a hosted theme, regenerate the download manifest: + +```bash +python3 scripts/generate-theme-manifest.py \ + --root ../crosspoint-tools/public/themes \ + --base-url http://crosspointreader.com/themes \ + --output ../crosspoint-tools/public/themes/themes.json +``` + +The script scans every theme folder, includes every file in each package, and writes size and CRC32 values used by the device downloader. Commit changed theme package files and the regenerated `themes.json` in `crosspoint-tools`. + --- ## Documentation @@ -138,6 +155,7 @@ Conversion runs the firmware repo's `lib/EpdFont/scripts/fontconvert_sdcard.py` - [User Guide](./USER_GUIDE.md) - [Web server usage](./docs/webserver.md) - [Web server endpoints](./docs/webserver-endpoints.md) +- [SD-card theme creation](./docs/theme-creation.md) - [Project scope](./SCOPE.md) - [Contributing docs](./docs/contributing/README.md) diff --git a/docs/theme-creation.md b/docs/theme-creation.md new file mode 100644 index 00000000..2ed064b9 --- /dev/null +++ b/docs/theme-creation.md @@ -0,0 +1,881 @@ +# SD-card theme creation + +CrossPoint ships one built-in base theme, Lyra. Additional themes live on the SD card and are selected from Settings. A downloaded theme is just a folder containing a `theme.json` and optional assets such as 1-bit BMP icons. + +CrossPoint ignores unknown JSON fields. Other readers, such as CrossInk, can add their own fields under a namespaced object like `extensions.crossink` without breaking CrossPoint. + +## Folder layout + +Manual install paths: + +```text +/.themes//theme.json # hidden folder used by the downloader +/themes//theme.json # visible folder for manual installs +``` + +Hosted theme packages live in the tools repo under: + +```text +../crosspoint-tools/public/themes//theme.json +../crosspoint-tools/public/themes//icons/*.bmp +``` + +Theme ids must be path-safe: letters, numbers, `-`, and `_` only. Spaces are not accepted because ids are used in folder names, URLs, and settings. + +## Minimal theme + +```json +{ + "schema": 1, + "id": "my-theme", + "name": "My Theme", + "description": "Short user-facing description shown in the downloader.", + "inherits": "lyra", + "metrics": { + "homeTopPadding": 48, + "menuRowHeight": 42 + }, + "components": { + "homeMenu": { + "font": "medium", + "style": "regular", + "centeredText": true, + "selectionStyle": "underline", + "showIcons": false + } + }, + "devices": { + "x3": { + "constraints": { + "screenWidth": 480, + "screenHeight": 800, + "frontButtons": 4, + "sideButtons": "up-down" + } + }, + "x4": { + "constraints": { + "screenWidth": 480, + "screenHeight": 800, + "frontButtons": 0, + "sideButtons": "up-down" + } + } + } +} +``` + +Top-level fields: + +- `schema`: currently `1`. +- `id`: stable id used for settings, folder name, and downloads. +- `name`: display name shown in Settings and the downloader. +- `description`: short downloader text. +- `inherits`: `lyra` for normal SD themes. `classic` is accepted for manually installed themes that intentionally build from the Classic renderer. +- `metrics`: layout numbers shared across screens. +- `components`: style rules for themeable UI surfaces. +- `assets.icons`: optional icon file map. +- `devices`: optional per-device overrides keyed by `x3` or `x4`. +- `requires`: optional metadata for other tooling. CrossPoint currently ignores it. +- `extensions`: optional namespaced metadata for other firmware/apps. CrossPoint currently ignores it. + +## Device overrides + +The active device id is `x3` or `x4`. Any supported field under `devices.` overrides the top-level value: + +```json +{ + "metrics": { + "homeCoverHeight": 300 + }, + "components": { + "homeRecents": { + "maxBooks": 3 + } + }, + "devices": { + "x3": { + "metrics": { + "homeCoverHeight": 280 + }, + "components": { + "homeRecents": { + "maxBooks": 3 + } + } + } + } +} +``` + +Use `constraints` to document intended screen and button assumptions for builders and compatible apps: + +```json +"constraints": { + "screenWidth": 480, + "screenHeight": 800, + "frontButtons": 4, + "sideButtons": "up-down" +} +``` + +CrossPoint parses these constraints but does not reject themes when they do not match. + +## Metrics + +Metrics tune global spacing and layout. Any omitted metric keeps Lyra's default. + +Common home/list metrics: + +- `topPadding`: top inset above normal page headers. +- `headerHeight`: default header band height for non-home screens. +- `verticalSpacing`: default vertical gap between major screen regions. +- `contentSidePadding`: left/right inset used by default list and menu renderers. +- `listRowHeight`: row height for single-line lists. +- `listWithSubtitleRowHeight`: row height for two-line lists such as Recent Books. +- `menuRowHeight`: height of one home menu tile. In `launcherGrid`, this is used by `drawButtonMenu` inside each grid cell; it is not the gap between grid cells. +- `menuSpacing`: vertical spacing between items when rendering a plain one-column home menu. It does not affect `launcherGrid`, because each grid cell is rendered as a one-item menu. +- `tabSpacing`: spacing between tab labels. +- `tabBarHeight`: height of the settings tab bar. +- `scrollBarWidth`: list scrollbar width. +- `scrollBarRightOffset`: list scrollbar inset from the right edge. +- `homeTopPadding`: top inset before the home cover/recent-books area in the legacy home renderer. +- `homeCoverHeight`: cover image height used by home recents. +- `homeCoverTileHeight`: total home recents tile/slot height, including cover title space when applicable. +- `homeRecentBooksCount`: number of recent books to request/render on home. +- `homeContinueReadingInMenu`: whether Continue Reading is part of the home launcher/menu actions. +- `homeShowContinueReadingHeader`: whether the current book title can appear in the home header. +- `homeMenuTopOffset`: legacy/manual home menu offset below the cover area. SD `screens.home.layout` themes should prefer explicit layout slots such as `carouselMenuGap`. +- `buttonHintsHeight`: bottom button-hint band height. +- `sideButtonHintsWidth`: side button-hint band width. + +Other supported metric groups: + +- Battery: `batteryWidth`, `batteryHeight`, `batteryBarHeight` +- Reader progress/status: `progressBarHeight`, `progressBarMarginTop`, `statusBarHorizontalMargin`, `statusBarVerticalMargin` +- Keyboard: `keyboardKeyWidth`, `keyboardKeyHeight`, `keyboardKeySpacing`, `keyboardBottomKeyHeight`, `keyboardBottomKeySpacing`, `keyboardBottomAligned`, `keyboardCenteredText`, `keyboardVerticalOffset`, `keyboardTextFieldWidthPercent`, `keyboardWidthPercent`, `keyboardKeyCornerRadius`, `keyboardFillUnselected`, `keyboardOutlineAllUnselected`, `keyboardDrawSpecialOutlineWhenUnselected`, `keyboardSecondaryLabelRightPadding`, `keyboardSecondaryLabelTopPadding`, `keyboardMinArrowHeadSize` +- Popups: `popupTopOffsetRatio`, `popupMarginX`, `popupMarginY`, `popupFrameThickness`, `popupCornerRadius`, `popupTextBold`, `popupTextInverted`, `popupTextBaselineOffsetY`, `popupProgressBarHeight`, `popupProgressDrawOutline`, `popupProgressClampPercent`, `popupProgressFillInverted`, `popupProgressOutlineInverted` +- Text fields: `textFieldHorizontalPadding`, `textFieldNormalThickness`, `textFieldCursorThickness`, `textFieldLineEndOffset` + +## Screen Layouts + +Themes can define `screens..layout` to place UI regions with the SDK row/column layout system. This is the preferred path for new SD themes. + +Each layout node can contain: + +- `id`: slot name used by widgets or firmware renderers. +- `axis`: `column` stacks children top-to-bottom; `row` lays children left-to-right. +- `gap`: pixels inserted between this node's direct children. +- `slots`: child layout nodes. +- `fixed`: exact pixel size along the parent axis. +- `flex`: proportional size after fixed children and gaps are subtracted. +- `token`: named size from `metrics`, such as `menuRow`, `recents`, `buttons`, `header`, `row`, `subtitleRow`, or `gap`. + +Example: + +```json +"screens": { + "home": { + "navigation": "linear", + "layout": { + "axis": "column", + "gap": 0, + "slots": [ + { + "id": "header", + "fixed": 40, + "axis": "row", + "gap": 4, + "slots": [ + { "id": "homeClock", "fixed": 52 }, + { "id": "homeTitle", "flex": 1 }, + { "id": "homeBattery", "fixed": 66 } + ] + }, + { "id": "recents", "fixed": 340 }, + { "id": "carouselMenuGap", "fixed": 36 }, + { "id": "launchers", "fixed": 192 }, + { "id": "homeSpacer", "flex": 1 }, + { "id": "buttons", "fixed": 40 } + ] + } + } +} +``` + +Important layout rules: + +- A parent layout's `gap` only affects its direct child slots. +- `fixed` and `flex` decide how much space a slot receives. They do not decide how a widget draws inside that slot. +- Widget-specific `gap` fields control spacing inside that widget. +- Named spacer slots such as `carouselMenuGap` and `homeSpacer` do not draw anything unless a widget targets them. They are useful for placing visible regions without manual `x`/`y` coordinates. +- If a screen layout is invalid or missing required slots, CrossPoint falls back to the built-in Lyra-safe layout for that screen. + +Home `navigation` modes: + +- `linear`: default. Front/side navigation buttons all move through the visible home actions as one ordered list. +- `splitAxis`: front left/right move through launcher actions; side up/down move through recent-book actions. Bottom button hints show Left/Right. +- `carousel`: front left/right move through recent-book actions; side up/down move through launcher actions. Use this when left/right should stay inside a cover carousel and up/down should enter or leave the launcher menu. + +### Layouts vs widgets + +Layouts only create named rectangles. They do not choose whether a screen is a list, cover grid, carousel, or any other presentation. + +Widgets choose what renders inside those rectangles. This keeps themes explicit and prevents firmware from guessing a grid just because a screen has a `list` slot. + +For `screens.recentBooks`, use: + +- No `recentBooks` screen: use the built-in Lyra recent-books screen. +- `layout` only, or a `list` widget: use the normal themed recent-books list in the `list` slot. +- A `coverGrid` widget: use FreeInkUI's cover-grid component in the target slot. + +Minimal themed list example: + +```json +"recentBooks": { + "layout": { + "axis": "column", + "gap": 8, + "slots": [ + { "id": "header", "fixed": 48 }, + { "id": "list", "flex": 1 }, + { "id": "buttons", "fixed": 40 } + ] + }, + "widgets": [ + { "slot": "list", "type": "list" } + ] +} +``` + +Cover-grid screen example: + +```json +"recentBooks": { + "layout": { + "axis": "column", + "gap": 16, + "slots": [ + { "id": "header", "fixed": 48 }, + { "id": "list", "flex": 1 }, + { "id": "buttons", "fixed": 40 } + ] + }, + "widgets": [ + { + "slot": "list", + "type": "coverGrid", + "columns": 3, + "rowGap": 36, + "coverWidth": 92, + "coverHeight": 132, + "rowHeight": 172, + "labelLines": 2, + "selectionStyle": "coverFrame" + } + ] +} +``` + +Do not use screen-level `coverGrid`. Cover-grid settings belong on a widget with `type: "coverGrid"`. + +### Home widgets + +Home layouts use `screens.home.widgets` to map slot rectangles to visible content. + +Supported widget types: + +- `clock`: draws the clock when the device has RTC support. On devices without clock support, the slot stays empty. +- `headerTitle`: draws the normal home/header title. +- `battery`: draws the battery indicator. +- `recents`: draws the configured home recents component. +- `recentCoverGrid`: draws recent books with FreeInkUI's `coverGrid` component. +- `launcherList`: draws actions as one vertical menu inside its slot. +- `launcherGrid`: draws actions in a row/column grid inside its slot. +- `buttonHints`: draws bottom button hints. + +`launcherGrid` fields: + +- `slot`: slot id to render into. +- `presentation`: optional presentation style. Use `iconTabs` for icon-only launcher tabs with outlined unselected cells and filled selected cells. +- `columns`: number of grid columns. +- `rows`: optional fixed row count. If omitted, rows are derived from visible launcher count and columns. +- `gap`: pixels between grid cells, both horizontally and vertically. +- `items`: launcher actions. Each item accepts `text`, `icon`, and `action`. + +All home widgets also support visual placement fields: + +- `layer`: draw order. Lower layers draw first; higher layers paint on top. Widgets with the same layer keep JSON order. +- `offsetX`: moves the widget right after layout. Negative values move left. +- `offsetY`: moves the widget down after layout. Negative values move up. +- `bleed`: expands the widget draw rectangle outside its slot without changing layout. Use either a single number or `{ "top": 0, "right": 0, "bottom": 0, "left": 0 }`. +- `inset`: shrinks the widget draw rectangle inside its slot without changing layout. Use either a single number or `{ "top": 0, "right": 0, "bottom": 0, "left": 0 }`. + +Example overlap: + +```json +{ + "slot": "recents", + "type": "recents", + "layer": 0, + "bleed": { "bottom": 24 } +}, +{ + "slot": "launchers", + "type": "launcherGrid", + "layer": 10, + "offsetY": -12, + "columns": 2, + "gap": 24 +} +``` + +That keeps the structural row/column layout intact, but lets the launcher grid visually overlap the recents area by 12 pixels. + +`buttonHints` widget fields: + +- `labels.confirm` +- `labels.previous` +- `labels.next` +- `labels.back` + +Button-hint labels are localized semantic tokens, not literal UI strings. Supported tokens are `default`, `empty`, `back`, `home`, `select`, `confirm`, `open`, `toggle`, `up`, `down`, `left`, and `right`. `default` uses the firmware fallback for that navigation mode; `empty` renders no label for that button. + +Example carousel hints: + +```json +{ + "slot": "buttons", + "type": "buttonHints", + "labels": { + "confirm": "select", + "previous": "left", + "next": "right" + } +} +``` + +When `components.buttonHints.layout` is `shapes` or `icons`, these same localized tokens render as button shapes/icons where supported. + +Example icon tabs: + +```json +{ + "slot": "tabs", + "type": "launcherGrid", + "presentation": "iconTabs", + "columns": 5, + "rows": 1, + "gap": 6, + "iconSize": 32, + "selectedRadius": 5, + "items": [ + { "icon": "folder", "action": "activity:fileBrowser" }, + { "icon": "recent", "action": "activity:recentBooks" }, + { "icon": "library", "action": "activity:opds" } + ] +} +``` + +Launcher actions: + +- `activity:fileBrowser` +- `activity:recentBooks` +- `activity:opds` +- `activity:fileTransfer` +- `activity:settings` +- `activity:reader` + +For `launcherGrid`, the final cell height is: + +```text +(slot height - gap * (rows - 1)) / rows +``` + +Then each cell calls the themed home menu renderer with one item. That means: + +- Increase the widget `gap` to create more visible space between grid items. +- Increase the launcher slot `fixed` height if larger gaps need more total room. +- Use `menuRowHeight` to tune the selectable tile/text/icon band inside each cell. +- Do not expect `menuSpacing` to change `launcherGrid` spacing. + +For a 3-row launcher grid with `menuRowHeight: 48` and `gap: 24`, use a launcher slot near: + +```text +3 * 48 + 2 * 24 = 192 +``` + +`recentCoverGrid` / recent-books `coverGrid` widget fields: + +- `slot`: slot id to render into. +- `columns`: grid columns. +- `rows`: grid rows. +- `gap`: horizontal pixels between cells. Also used vertically when `rowGap` is omitted. +- `rowGap`: vertical pixels between cover-grid rows. +- `cellInset`: optional padding inside each cover-grid cell, before the cover and label are drawn. +- `labelInset`: optional padding inside the title label area. Use `{ "left": 5, "right": 5 }` to keep two-line titles away from cell edges. +- `coverWidth`: rendered cover width. +- `coverHeight`: rendered cover height and thumbnail size to generate. +- `placeholderIconSize`: maximum icon size for the missing-cover placeholder. +- `rowHeight`: height of each cell row, including label space. +- `labelHeight`: title label area below each cover. Use `0` to hide titles. +- `labelGap`: vertical pixels between the cover and title label block. +- `labelLines`: maximum title lines to render. Increase `rowHeight` when this is greater than `1`. +- `selectionStyle`: `fill`, `outline`, `coverFrame`, or `none`. Prefer `coverFrame` for cover grids because it frames only the thumbnail and does not depend on title wrapping. +- `startIndex`: first recent-book index to show. Use `2` when a featured area already uses the first two books. + +These cover-grid widgets use FreeInkUI's `coverGrid` for layout, labels, cell styling, and selected state. CrossPoint supplies a cover painter callback so SD-card thumbnails render from the existing recent-book cache. + +Cover widgets can use different visual `coverWidth` and `coverHeight` values on different screens. CrossPoint still generates and reads one largest-needed thumbnail height for the active theme, then scales/crops it into each widget. That keeps the same book cover available on home and recent-books instead of requiring separate BMPs per widget. + +`featuredBookCard` fields: + +- `coverWidth`, `coverHeight`: rendered cover size and thumbnail height to generate. +- `placeholderIconSize`: maximum icon size for the missing-cover placeholder. +- `coverGap`: horizontal gap between the cover and title/author text. +- `titleGap`: vertical gap below the Continue Reading label before the book card starts. +- `startIndex`: recent-book index to show. + +## Components + +### Fonts + +Most components accept: + +```json +"font": "large", +"style": "bold" +``` + +Supported `font` values are `small`, `medium`, and `large`. + +Semantic aliases are also accepted: + +- `chrome`, `caption`: same as `small`. +- `body`, `label`: same as `medium`. +- `title`, `display`: same as `large`. + +Supported `style` values are `regular` and `bold`. + +### Home recents + +`components.homeRecents` controls the home cover area. + +Supported types: + +- `default`: Lyra default. +- `none`: no cover area. +- `cover-strip`: one or more cover slots. + +Example: + +```json +"homeRecents": { + "type": "cover-strip", + "maxBooks": 3, + "wrap": true, + "selectionLineWidth": 3, + "inactiveSelectionLineWidth": 1, + "selectionCornerRadius": 6, + "slots": [ + { + "book": "previous", + "x": "padding", + "y": "center", + "height": 210, + "widthPercent": 62 + }, + { + "book": "selected", + "x": "center", + "y": "top", + "height": 280, + "widthPercent": 62, + "selected": true, + "title": { + "enabled": true, + "font": "large", + "style": "bold", + "maxLines": 2, + "offsetY": 12 + } + }, + { + "book": "next", + "x": "right-padding", + "y": "center", + "height": 210, + "widthPercent": 62 + } + ] +} +``` + +Slot fields: + +- `book`: `selected`, `previous`, `next`, or `index`. +- `bookIndex`: zero-based index when `book` is `index`. +- `x`: `padding`, `center`, or `right-padding`. +- `y`: `top` or `center`. +- `height`: requested thumbnail height. CrossPoint generates/cache-misses thumbnails at requested sizes. +- `widthPercent`: cover width as a percent of the slot height. +- `xOffset`, `yOffset`: positional adjustments. +- `selected`: whether this slot receives the active selection outline. +- `title`: optional book title under the cover. + +CrossPoint currently reads up to five cover slots. + +Cover slots with `selected: true` draw after unselected slots, so selected covers appear in front. Within each group, slots draw in the same order they appear in JSON. For a carousel where the side covers sit behind the middle cover, mark the middle slot as `selected: true`. + +Use `xOffset` and `yOffset` for small relative adjustments after `x`/`y` placement has been resolved: + +- Positive `xOffset` moves a cover right. +- Negative `xOffset` moves a cover left. +- Positive `yOffset` moves a cover down. +- Negative `yOffset` moves a cover up. + +Example carousel layering: + +```json +"slots": [ + { + "book": "previous", + "x": "padding", + "y": "center", + "height": 225, + "widthPercent": 62, + "xOffset": 32 + }, + { + "book": "next", + "x": "right-padding", + "y": "center", + "height": 225, + "widthPercent": 62, + "xOffset": -32 + }, + { + "book": "selected", + "x": "center", + "y": "top", + "height": 300, + "widthPercent": 62, + "selected": true + } +] +``` + +In that example, the side covers are pushed toward the center, and the selected cover is drawn in the foreground. + +### Home menu + +`components.homeMenu` styles the home menu options. + +Supported fields: + +- `font`, `style`, `bold` +- `centeredText` +- `centerVertically` +- `showIcons` +- `panelWidth` +- `drawPanel` +- `panelCornerRadius` +- `selectionStyle`: `fill`, `outline`, `triangle`, `underline`, or `pill` +- `selectionCornerRadius` +- `selectionInset` +- `selectedTextInverted` +- `selectionFillBlack` +- `rowPaddingX` +- `textInsetX` + +### Lists + +`components.list` styles Settings, Browse, Recent Books, and similar list rows. + +Supported fields: + +- `font`, `style`, `bold` +- `subtitleFontId` +- `valueFontId` +- `showIcons` +- `iconSize` +- `textGap` +- `selectionStyle`: `fill`, `outline`, or `underline` +- `selectionCornerRadius` +- `selectionFill` +- `selectionOutline` +- `selectedTextInverted` +- `rowBackgrounds` +- `centerSingleLineRows` +- `subtitleRowAutoHeight` +- `centerValueVertically` +- `rowSidePadding` +- `rowGap` +- `textInsetX` +- `selectionInsetX` +- `selectionInsetY` +- `titleOffsetY` +- `subtitleOffsetY` +- `subtitleTopPadding` +- `subtitleBottomPadding` +- `subtitleInterLineGap` +- `valueOffsetY` +- `subtitleValueOffsetY` +- `iconOffsetY` + +### Header + +`components.header` styles page headers. + +Supported fields: + +- `font`, `style`, `bold` +- `centeredTitle` +- `showDivider` +- `titleOffsetY` +- `batteryOffsetY` + +### Tab bar + +`components.tabBar` styles tabs. + +Supported fields: + +- `font`, `style`, `bold` +- `equalWidth` +- `selectionStyle`: `fill` or `underline` +- `selectedCornerRadius` +- `selectedTextInverted` +- `drawDivider` +- `horizontalInset` + +### Button hints + +`components.buttonHints` styles bottom and side button hints. + +Supported fields: + +- `font`, `style`, `bold` +- `layout`: `buttons`, `groups`, `shapes`, or `icons` +- `buttonWidth` +- `smallButtonHeight` +- `cornerRadius` +- `fill` +- `outline` +- `drawEmpty` +- `shapes` +- `sidePadding` +- `groupGap` +- `bottomMargin` +- `innerPadding` +- `shapeSize` +- `textOffsetY` + +Use `layout: "shapes"` or `layout: "icons"` for icon-only arrows/circle/square hints. + +### Reader chrome + +`screens.reader.chrome` styles the reader status lane. Reader chrome still uses `screens.reader.layout` slots for placement; the chrome object controls how those slots draw. + +Battery fields: + +- `style`: `icon` or `bar`. +- `width`: battery glyph width in pixels. +- `height`: battery glyph height in pixels. +- `offsetY`: vertical adjustment applied after the battery is positioned in its slot. Positive values move it down; negative values move it up. +- `track`: background/track style for bar batteries: `none`, `hairline`, `outline`, or `dither`. +- `fill`: fill style for bar batteries: `solid`, `dither`, or `segments`. +- `direction`: fill direction: `left-to-right`, `right-to-left`, `center-out`, `bottom-to-top`, or `top-to-bottom`. +- `orientation`: `horizontal` or `vertical`. Vertical is also implied by `bottom-to-top` and `top-to-bottom`. +- `caps`: `square` or `pixel`. `pixel` trims the four filled corners for a softer e-ink cap. +- `segments`: number of filled blocks when `fill` is `segments`. +- `segmentGap`: pixels between segments. +- `radius`: rounded-rect radius for bar track/fill/segments. Keep this small for thin e-ink bars; `0` is square. +- `showPercentage`: whether reader chrome may draw the battery percentage when the global setting allows it. + +Example: + +```json +"screens": { + "reader": { + "layout": { + "axis": "row", + "gap": 8, + "slots": [ + { "id": "bookmark", "fixed": 18 }, + { "id": "battery", "fixed": 38 }, + { "id": "title", "flex": 1 }, + { "id": "clock", "fixed": 42 }, + { "id": "progress", "fixed": 82 } + ] + }, + "chrome": { + "battery": { + "style": "bar", + "width": 38, + "height": 3, + "offsetY": 1, + "track": "none", + "fill": "solid", + "direction": "left-to-right", + "radius": 0, + "showPercentage": false + } + } + } +} +``` + +## Icons + +Icons are optional. If both `homeMenu.showIcons` and `list.showIcons` are false, omit `assets.icons` and the icon files to reduce download size and heap use. + +Supported icon keys: + +- `folder`, `folder24` +- `text`, `text24` +- `image`, `image24` +- `book`, `book24` +- `file`, `file24` +- `recent` +- `settings`, `settings2` +- `transfer` +- `library` +- `wifi` +- `hotspot` +- `bookmark` + +Generate firmware-matching 1-bit BMP icons: + +```bash +python3 scripts/generate-theme-icons.py \ + --icons src/components/icons \ + --themes ../crosspoint-tools/public/themes +``` + +The script writes rotated BMP files into each `../crosspoint-tools/public/themes//icons/` folder. + +Reference them from `theme.json`: + +```json +"assets": { + "icons": { + "folder": "icons/folder.bmp", + "book": "icons/book.bmp", + "settings": "icons/settings2.bmp" + } +} +``` + +## CrossInk and extension fields + +CrossPoint only consumes the fields documented above. Unknown fields are ignored, so theme authors can include extra data for compatible apps and firmware. + +Put app-specific fields under `extensions.`: + +```json +{ + "schema": 1, + "id": "crossink-stats", + "name": "CrossInk Stats", + "inherits": "lyra", + "components": { + "homeRecents": { + "type": "cover-strip", + "maxBooks": 1 + } + }, + "extensions": { + "crossink": { + "schema": 1, + "readingStats": { + "enabled": true, + "placement": "home-footer", + "font": "small", + "style": "regular", + "show": [ + "currentStreak", + "readingTime", + "pagesRead", + "percentComplete" + ], + "labels": { + "currentStreak": "streak", + "readingTime": "reading", + "pagesRead": "pages" + } + } + } + } +} +``` + +Recommended extension rules: + +- Keep CrossPoint layout fields in `metrics`, `components`, `assets`, and `devices`. +- Keep CrossInk-only fields under `extensions.crossink`. +- Add an extension-local `schema` when the app-specific format may evolve. +- Prefer declarative fields such as `placement`, `font`, `show`, and `labels` over code-like strings. +- Keep extension data compact. CrossPoint ignores it, but it is still parsed transiently when discovering themes. +- Do not put required CrossPoint behavior only in an extension field. CrossPoint will not read it. + +CrossInk can also use `requires` for compatibility metadata: + +```json +"requires": { + "crosspoint": { + "schema": 1, + "modules": ["cover-strip"] + }, + "crossink": { + "schema": 1, + "modules": ["reading-stats"] + } +} +``` + +CrossPoint currently treats `requires` as metadata. + +## Package manifest + +After adding or changing hosted themes, regenerate `themes.json` in `crosspoint-tools`: + +```bash +python3 scripts/generate-theme-manifest.py \ + --root ../crosspoint-tools/public/themes \ + --base-url http://crosspointreader.com/themes \ + --output ../crosspoint-tools/public/themes/themes.json +``` + +The manifest generator: + +- scans every `../crosspoint-tools/public/themes//theme.json` +- includes every file in each theme folder +- writes per-file `size` and `crc32` +- writes the theme `id`, `name`, `description`, and `totalSize` + +Commit the theme files and the regenerated manifest together in `crosspoint-tools`. + +## Validation checklist + +Before publishing: + +```bash +for f in ../crosspoint-tools/public/themes/themes.json ../crosspoint-tools/public/themes/*/theme.json; do + python3 -m json.tool "$f" >/dev/null +done + +python3 scripts/generate-theme-manifest.py \ + --root ../crosspoint-tools/public/themes \ + --base-url http://crosspointreader.com/themes \ + --output ../crosspoint-tools/public/themes/themes.json + +pio run -e gh_release +``` + +On device: + +1. Download the theme from Settings -> UI Theme -> Download Themes. +2. Exit the downloader and let the device silently restart to clear WiFi/TLS heap. +3. Return to Settings -> UI Theme and select the downloaded theme. +4. Check Home, Settings, Browse, Recent Books, button hints, tabs, popups, keyboard, and reader menus. diff --git a/freeink-sdk b/freeink-sdk index 8be32366..329a4beb 160000 --- a/freeink-sdk +++ b/freeink-sdk @@ -1 +1 @@ -Subproject commit 8be32366091e3e0eadeda9b983091166140f816f +Subproject commit 329a4bebef0df10aecd3737f5676c832f03ba277 diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index b024fab8..2bd5551f 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -8,6 +8,7 @@ #include #include +#include #include "FontCacheManager.h" @@ -1055,7 +1056,19 @@ void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, co } void GfxRenderer::drawIcon(const uint8_t bitmap[], const int x, const int y, const int width, const int height) const { - display.drawImageTransparent(bitmap, y, getScreenWidth() - width - x, height, width); + if (bitmap == nullptr || width <= 0 || height <= 0) return; + assert(width == height); + + const int bytesPerRow = (width + 7) / 8; + for (int sourceY = 0; sourceY < height; ++sourceY) { + for (int sourceX = 0; sourceX < width; ++sourceX) { + const uint8_t rowByte = bitmap[sourceY * bytesPerRow + sourceX / 8]; + const bool background = (rowByte >> (7 - (sourceX % 8))) & 0x01; + if (background) continue; + + drawPixel(x + height - 1 - sourceY, y + sourceX, true); + } + } } void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, const int maxWidth, const int maxHeight, diff --git a/lib/I18n/translations/belarusian.yaml b/lib/I18n/translations/belarusian.yaml index de79ac91..a8d68988 100644 --- a/lib/I18n/translations/belarusian.yaml +++ b/lib/I18n/translations/belarusian.yaml @@ -300,3 +300,4 @@ STR_SLEEP_TIMER_STEP_HINT: "Улева/Управа: 1 хв Уверх/Уніз STR_AUTO_TURN_ENABLED: "Аўтаперагортванне: " STR_AUTO_TURN_PAGES_PER_MIN: "Аўтаперагортванне (старонак за хвіліну)" STR_TILT_PAGE_TURN: "Перагортванне нахілам" +STR_MANAGE_THEMES: "Кіраванне тэмамі" diff --git a/lib/I18n/translations/catalan.yaml b/lib/I18n/translations/catalan.yaml index c1bf6c6b..26cc92f6 100644 --- a/lib/I18n/translations/catalan.yaml +++ b/lib/I18n/translations/catalan.yaml @@ -383,3 +383,4 @@ STR_FIRMWARE_WRITE_FAILED: "Ha fallat l'escriptura del firmware" STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "No apagueu el dispositiu!" STR_RECOVERY_MODE: "Mode de recuperació" STR_RECOVERY_MODE_HINT: "Poseu firmware.bin a l'arrel de la targeta SD i seleccioneu-lo" +STR_MANAGE_THEMES: "Gestiona els temes" diff --git a/lib/I18n/translations/czech.yaml b/lib/I18n/translations/czech.yaml index 696cdd33..eaf18be6 100644 --- a/lib/I18n/translations/czech.yaml +++ b/lib/I18n/translations/czech.yaml @@ -275,3 +275,4 @@ STR_SLEEP_TIMER_VALUE_FORMAT: "%u min" STR_SLEEP_NEVER: "Nikdy" STR_SLEEP_TIMER_STEP_HINT: "Vlevo/Vpravo: 1 min Nahoru/Dolů: 5 min" STR_TILT_PAGE_TURN: "Otáčení stránek nakloněním" +STR_MANAGE_THEMES: "Spravovat motivy" diff --git a/lib/I18n/translations/danish.yaml b/lib/I18n/translations/danish.yaml index cd4ba1ba..054b5a46 100644 --- a/lib/I18n/translations/danish.yaml +++ b/lib/I18n/translations/danish.yaml @@ -303,3 +303,4 @@ STR_SCREENSHOT_BUTTON: "Tag skærmbillede" STR_AUTO_TURN_ENABLED: "Automatisk sidevendning aktiveret: " STR_AUTO_TURN_PAGES_PER_MIN: "Automatisk vending (sider per minut)" STR_TILT_PAGE_TURN: "Vip for at vende side" +STR_MANAGE_THEMES: "Administrer temaer" diff --git a/lib/I18n/translations/dutch.yaml b/lib/I18n/translations/dutch.yaml index ee4ca64e..e8278436 100644 --- a/lib/I18n/translations/dutch.yaml +++ b/lib/I18n/translations/dutch.yaml @@ -303,3 +303,4 @@ STR_SCREENSHOT_BUTTON: "Screenshot maken" STR_AUTO_TURN_ENABLED: "Automatisch omslaan ingeschakeld: " STR_AUTO_TURN_PAGES_PER_MIN: "Autom. omslaan (pagina's per minuut)" STR_TILT_PAGE_TURN: "Kantel om te bladeren" +STR_MANAGE_THEMES: "Thema's beheren" diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index ecd7b0e0..86911277 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -350,6 +350,7 @@ STR_INSTALLED: "Installed" STR_DOWNLOAD_ALL: "Download All" STR_UPDATE_ALL: "Update All" STR_UPDATE_AVAILABLE: "Update" +STR_MANAGE_THEMES: "Manage Themes" STR_CRASH_TITLE: "System Crash" STR_CRASH_DESCRIPTION: "A detailed report was saved to crash_report.txt. Please include this file in your bug report." STR_CRASH_REASON: "Crash reason:" diff --git a/lib/I18n/translations/finnish.yaml b/lib/I18n/translations/finnish.yaml index ebe120c1..d229c36c 100644 --- a/lib/I18n/translations/finnish.yaml +++ b/lib/I18n/translations/finnish.yaml @@ -273,3 +273,4 @@ STR_SLEEP_TIMER_VALUE_FORMAT: "%u min" STR_SLEEP_NEVER: "Ei koskaan" STR_SLEEP_TIMER_STEP_HINT: "Vasen/Oikea: 1 min Ylös/Alas: 5 min" STR_TILT_PAGE_TURN: "Sivunkääntö kallistamalla" +STR_MANAGE_THEMES: "Hallinnoi teemoja" diff --git a/lib/I18n/translations/french.yaml b/lib/I18n/translations/french.yaml index db4db7ec..0243f2e7 100644 --- a/lib/I18n/translations/french.yaml +++ b/lib/I18n/translations/french.yaml @@ -304,3 +304,4 @@ STR_SCREENSHOT_BUTTON: "Capture d'écran" STR_AUTO_TURN_ENABLED: "Tourne-page auto : " STR_AUTO_TURN_PAGES_PER_MIN: "Tourne-page auto (pages par minute)" STR_TILT_PAGE_TURN: "Tourner par inclinaison" +STR_MANAGE_THEMES: "Gérer les thèmes" diff --git a/lib/I18n/translations/german.yaml b/lib/I18n/translations/german.yaml index a7c1ad08..59d1bea1 100644 --- a/lib/I18n/translations/german.yaml +++ b/lib/I18n/translations/german.yaml @@ -380,3 +380,4 @@ STR_FIRMWARE_WRITE_FAILED: "Schreiben der Firmware-Datei ist fehlgeschlagen" STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Nicht ausschalten!" STR_RECOVERY_MODE: "Wiederherstellungsmodus" STR_RECOVERY_MODE_HINT: "Lege firmware.bin im SD-Kartenwurzelverzeichnis ab und wähle es aus" +STR_MANAGE_THEMES: "Designs verwalten" diff --git a/lib/I18n/translations/hebrew.yaml b/lib/I18n/translations/hebrew.yaml index 7d0835cf..d4b74070 100644 --- a/lib/I18n/translations/hebrew.yaml +++ b/lib/I18n/translations/hebrew.yaml @@ -384,6 +384,7 @@ STR_BOOKMARK_REMOVED: "הסימנייה הוסרה" STR_QUICK_RESUME: "חזרה מהירה" STR_REMOVE_FROM_RECENTS: "להסיר מרשימת הספרים האחרונים?" STR_CONFIRM_DELETE_BOOKMARK: "למחוק סימנייה זו?" +STR_MANAGE_THEMES: "ניהול ערכות נושא" STR_LONG_PRESS_MENU: "לחיצה ארוכה על אישור" STR_KOSYNC: "KOSync" STR_BOOKMARK_OPTION: "סימנייה" diff --git a/lib/I18n/translations/hungarian.yaml b/lib/I18n/translations/hungarian.yaml index b6f10036..4c704212 100644 --- a/lib/I18n/translations/hungarian.yaml +++ b/lib/I18n/translations/hungarian.yaml @@ -300,3 +300,4 @@ STR_SCREENSHOT_BUTTON: "Képernyőkép készítése" STR_AUTO_TURN_ENABLED: "Automatikus lapozás bekapcsolva: " STR_AUTO_TURN_PAGES_PER_MIN: "Automatikus lapozás (oldal/perc)" STR_TILT_PAGE_TURN: "Döntéses lapozás" +STR_MANAGE_THEMES: "Témák kezelése" diff --git a/lib/I18n/translations/italian.yaml b/lib/I18n/translations/italian.yaml index f799e256..35a87cb8 100644 --- a/lib/I18n/translations/italian.yaml +++ b/lib/I18n/translations/italian.yaml @@ -376,6 +376,8 @@ STR_CLOCK_SYNCING: "Sincronizzazione con il server NTP..." STR_CLOCK_SYNC_FAIL: "Sincronizzazione non riuscita" STR_CLOCK_SYNC_NOW: "Sincronizza l'orologio adesso" STR_CLOCK_SYNC_NO_WIFI: "Wi-Fi non connesso" +STR_HOLD_CONFIRM_TO_DELETE: "Tieni premuto Conferma per cancellare" +STR_MANAGE_THEMES: "Gestisci temi" STR_HOLD_OPEN_TO_DELETE: "Tieni premuto Apri per eliminare" STR_NEXT_FIELD: "Succ." STR_CURRENT_TIME: "Ora attuale: " diff --git a/lib/I18n/translations/kazakh.yaml b/lib/I18n/translations/kazakh.yaml index 067aaa56..720532a6 100644 --- a/lib/I18n/translations/kazakh.yaml +++ b/lib/I18n/translations/kazakh.yaml @@ -299,3 +299,4 @@ STR_SCREENSHOT_BUTTON: "Скриншот түсіру" STR_AUTO_TURN_ENABLED: "Автоматты бет аудару қосулы: " STR_AUTO_TURN_PAGES_PER_MIN: "Автоматты бет аудару (минутына бет саны)" STR_TILT_PAGE_TURN: "Еңкейту арқылы бет аудару" +STR_MANAGE_THEMES: "Тақырыптарды басқару" diff --git a/lib/I18n/translations/lithuanian.yaml b/lib/I18n/translations/lithuanian.yaml index f79a3222..b6ff95b9 100644 --- a/lib/I18n/translations/lithuanian.yaml +++ b/lib/I18n/translations/lithuanian.yaml @@ -300,3 +300,4 @@ STR_SCREENSHOT_BUTTON: "Ekrano nuotrauka" STR_AUTO_TURN_ENABLED: "Auto-vertimas: " STR_AUTO_TURN_PAGES_PER_MIN: "Auto-vertimas (psl/min)" STR_TILT_PAGE_TURN: "Puslapio vertimas pakreipiant" +STR_MANAGE_THEMES: "Tvarkyti temas" diff --git a/lib/I18n/translations/polish.yaml b/lib/I18n/translations/polish.yaml index 0cb8f698..7d75dc96 100644 --- a/lib/I18n/translations/polish.yaml +++ b/lib/I18n/translations/polish.yaml @@ -360,3 +360,4 @@ STR_FIRMWARE_WRITE_FAILED: "Zapis oprogramowania nieudany" STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Nie wyłączać!" STR_RECOVERY_MODE: "Tryb przywracania" STR_RECOVERY_MODE_HINT: "Umieść firmware.bin w głównym katalogu karty SD i wybierz go" +STR_MANAGE_THEMES: "Zarządzaj motywami" diff --git a/lib/I18n/translations/portuguese.yaml b/lib/I18n/translations/portuguese.yaml index 7b68383c..46efa0d8 100644 --- a/lib/I18n/translations/portuguese.yaml +++ b/lib/I18n/translations/portuguese.yaml @@ -275,3 +275,4 @@ STR_SLEEP_TIMER_VALUE_FORMAT: "%u min" STR_SLEEP_NEVER: "Nunca" STR_SLEEP_TIMER_STEP_HINT: "Esq/Dir: 1 min Cima/Baixo: 5 min" STR_TILT_PAGE_TURN: "Virar página por inclinação" +STR_MANAGE_THEMES: "Gerenciar temas" diff --git a/lib/I18n/translations/romanian.yaml b/lib/I18n/translations/romanian.yaml index 0fbae3c4..71ffbb68 100644 --- a/lib/I18n/translations/romanian.yaml +++ b/lib/I18n/translations/romanian.yaml @@ -303,3 +303,4 @@ STR_SCREENSHOT_BUTTON: "Captură ecran" STR_AUTO_TURN_ENABLED: "Răsfoire automată: " STR_AUTO_TURN_PAGES_PER_MIN: "Pagini pe minut" STR_TILT_PAGE_TURN: "Întoarcere pagină prin înclinare" +STR_MANAGE_THEMES: "Gestionează temele" diff --git a/lib/I18n/translations/russian.yaml b/lib/I18n/translations/russian.yaml index bac25717..1402b4d4 100644 --- a/lib/I18n/translations/russian.yaml +++ b/lib/I18n/translations/russian.yaml @@ -383,3 +383,4 @@ STR_FIRMWARE_WRITE_FAILED: "Ошибка записи прошивки" STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Не выключайте питание!" STR_RECOVERY_MODE: "Режим восстановления" STR_RECOVERY_MODE_HINT: "Поместите firmware.bin в корень SD-карты и выберите его" +STR_MANAGE_THEMES: "Управление темами" diff --git a/lib/I18n/translations/slovenian.yaml b/lib/I18n/translations/slovenian.yaml index 317bcb43..22319476 100644 --- a/lib/I18n/translations/slovenian.yaml +++ b/lib/I18n/translations/slovenian.yaml @@ -300,3 +300,4 @@ STR_SCREENSHOT_BUTTON: "Naredi posnetek zaslona" STR_AUTO_TURN_ENABLED: "Samodejno obračanje: " STR_AUTO_TURN_PAGES_PER_MIN: "Samodejno obračanje (strani na minuto)" STR_TILT_PAGE_TURN: "Obračanje s priklonom" +STR_MANAGE_THEMES: "Upravljanje tem" diff --git a/lib/I18n/translations/spanish.yaml b/lib/I18n/translations/spanish.yaml index 0daeac75..2a0ad51b 100644 --- a/lib/I18n/translations/spanish.yaml +++ b/lib/I18n/translations/spanish.yaml @@ -383,3 +383,4 @@ STR_FIRMWARE_WRITE_FAILED: "Falló la escritura del firmware" STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "¡No apague el dispositivo!" STR_RECOVERY_MODE: "Modo de recuperación" STR_RECOVERY_MODE_HINT: "Ponga firmware.bin en la raíz de la tarj. SD y selecciónelo" +STR_MANAGE_THEMES: "Gestionar temas" diff --git a/lib/I18n/translations/swedish.yaml b/lib/I18n/translations/swedish.yaml index 19105830..7e333c63 100644 --- a/lib/I18n/translations/swedish.yaml +++ b/lib/I18n/translations/swedish.yaml @@ -380,3 +380,4 @@ STR_FIRMWARE_WRITE_FAILED: "Skrivning till firmware misslyckades" STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Stäng inte av!" STR_RECOVERY_MODE: "Återställningsläge" STR_RECOVERY_MODE_HINT: "Placera firmware.bin i SD-kortroten och välj den" +STR_MANAGE_THEMES: "Hantera teman" diff --git a/lib/I18n/translations/turkish.yaml b/lib/I18n/translations/turkish.yaml index ac6dd32c..c61b0bb8 100644 --- a/lib/I18n/translations/turkish.yaml +++ b/lib/I18n/translations/turkish.yaml @@ -303,3 +303,4 @@ STR_SELECTED: "Seçili" STR_SHOW: "Göster" STR_TITLE: "Başlık" STR_TILT_PAGE_TURN: "Eğerek sayfa çevirme" +STR_MANAGE_THEMES: "Temaları Yönet" diff --git a/lib/I18n/translations/ukrainian.yaml b/lib/I18n/translations/ukrainian.yaml index 6a34e1a9..0b6fad19 100644 --- a/lib/I18n/translations/ukrainian.yaml +++ b/lib/I18n/translations/ukrainian.yaml @@ -380,3 +380,4 @@ STR_FIRMWARE_WRITE_FAILED: "Помилка запису прошивки" STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Не вимикайте пристрій!" STR_RECOVERY_MODE: "Режим відновлення" STR_RECOVERY_MODE_HINT: "Помістіть firmware.bin у корінь SD-карти та виберіть його" +STR_MANAGE_THEMES: "Керування темами" diff --git a/lib/I18n/translations/valencian.yaml b/lib/I18n/translations/valencian.yaml index 89a7d962..4d89856c 100644 --- a/lib/I18n/translations/valencian.yaml +++ b/lib/I18n/translations/valencian.yaml @@ -383,3 +383,4 @@ STR_FIRMWARE_WRITE_FAILED: "Ha fallat l'escriptura del firmware" STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "No apagueu el dispositiu!" STR_RECOVERY_MODE: "Mode de recuperació" STR_RECOVERY_MODE_HINT: "Poseu firmware.bin a l'arrel de la targeta SD i seleccioneu-lo" +STR_MANAGE_THEMES: "Gestiona els temes" diff --git a/scripts/generate-theme-icons.py b/scripts/generate-theme-icons.py new file mode 100644 index 00000000..b80f6833 --- /dev/null +++ b/scripts/generate-theme-icons.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Export compiled 1-bit UI icon headers as BMP assets for SD themes.""" + +import argparse +import re +import struct +from pathlib import Path + + +ICON_HEADERS = [ + "book.h", + "book24.h", + "bookmark.h", + "cover.h", + "file24.h", + "folder.h", + "folder24.h", + "hotspot.h", + "image24.h", + "library.h", + "recent.h", + "settings2.h", + "text24.h", + "transfer.h", + "wifi.h", +] + + +def parse_icon_header(path: Path): + text = path.read_text() + size_match = re.search(r"//\s*size:\s*(\d+)x(\d+)", text) + if not size_match: + raise ValueError(f"missing size comment in {path}") + width = int(size_match.group(1)) + height = int(size_match.group(2)) + + bitmap_match = re.search(r"static\s+const\s+uint8_t\s+\w+\s*\[\]\s*=\s*\{(?P.*?)\};", text, re.DOTALL) + if not bitmap_match: + raise ValueError(f"missing bitmap data in {path}") + bitmap_body = bitmap_match.group("body") + + values = [int(m.group(1), 16) for m in re.finditer(r"0x([0-9A-Fa-f]{2})", bitmap_body)] + expected = ((width + 7) // 8) * height + if len(values) != expected: + raise ValueError(f"{path}: expected {expected} bytes, found {len(values)}") + return width, height, bytes(values) + + +def get_bit(bitmap: bytes, width: int, x: int, y: int) -> int: + stride = (width + 7) // 8 + return (bitmap[y * stride + x // 8] >> (7 - (x % 8))) & 1 + + +def set_bit(buf: bytearray, width: int, x: int, y: int, value: int): + stride = (width + 7) // 8 + if value: + buf[y * stride + x // 8] |= 1 << (7 - (x % 8)) + + +def rotate_1bit_cw(width: int, height: int, bitmap: bytes): + rotated_width = height + rotated_height = width + rotated = bytearray(((rotated_width + 7) // 8) * rotated_height) + for y in range(height): + for x in range(width): + set_bit(rotated, rotated_width, height - 1 - y, x, get_bit(bitmap, width, x, y)) + return rotated_width, rotated_height, bytes(rotated) + + +def write_1bit_bmp(path: Path, width: int, height: int, bitmap: bytes): + src_stride = (width + 7) // 8 + dst_stride = ((width + 31) // 32) * 4 + pixel_bytes = dst_stride * height + pixel_offset = 14 + 40 + 8 + file_size = pixel_offset + pixel_bytes + + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("wb") as out: + # BITMAPFILEHEADER + out.write(b"BM") + out.write(struct.pack("