diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml index c8bbac4..b244e37 100644 --- a/.github/workflows/build-macos.yml +++ b/.github/workflows/build-macos.yml @@ -48,7 +48,7 @@ jobs: - name: Build app bundle run: | - QT_QPA_PLATFORM=offscreen python tools/create_icon.py --png-iconset assets/AppIcon.iconset + QT_QPA_PLATFORM=offscreen python tools/create_icon.py --source assets/app-icon-source.png --macos-iconset assets/AppIcon.iconset iconutil -c icns assets/AppIcon.iconset -o assets/pdfreader_by_sparsh.icns pyinstaller --windowed --onedir --noupx --name "OpenReader" --argv-emulation --icon "assets/pdfreader_by_sparsh.icns" main.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dd12fc4..7e49f0b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -263,7 +263,7 @@ jobs: - name: Build macOS app run: | - QT_QPA_PLATFORM=offscreen python tools/create_icon.py --png-iconset assets/AppIcon.iconset + QT_QPA_PLATFORM=offscreen python tools/create_icon.py --source assets/app-icon-source.png --macos-iconset assets/AppIcon.iconset iconutil -c icns assets/AppIcon.iconset -o assets/pdfreader_by_sparsh.icns pyinstaller --windowed --onedir --noupx --name "OpenReader" --argv-emulation --icon "assets/pdfreader_by_sparsh.icns" main.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a1f56b..5266f7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## v1.2.1 — First Public Microsoft Store Release Candidate — 2026-06-18 + +- **Version:** Bumped `__version__` to `1.2.1`. +- **MSIX version:** `1.2.1.0`. +- **Purpose:** First public Microsoft Store release candidate. +- **UI/Branding only:** New application icon (1024×1024 source). No architectural changes. +- **Assets regenerated:** Windows .ico, MSIX brand images (44×44, 71×71, 150×150, 310×150, 620×300), macOS .iconset. +- **About dialog cleaned up:** Removed beta/dev/validation wording. Shows clean "OpenReader — Version 1.2.1 — Release Notes". +- **Frozen identity unchanged:** `SparshSam.OpenReader`, `CN=E6186421-BF8A-47E0-A89C-0F513DFF91C0`, PFN `SparshSam.OpenReader_yh0byntbzd2qw`. + ## v1.2.0-beta.6 — MSIX Update Validation — 2026-06-18 - **Version:** Bumped `__version__` to `1.2.0-beta.6-dev`. diff --git a/README.md b/README.md index 4c19aaf..ed0055e 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ OpenReader is a **stable, local-first desktop PDF utility** built with Python, P The app is intentionally local-first: PDFs are opened, rendered, searched, merged, split, annotated, and compressed on your computer — no uploads, no accounts, no telemetry. -**v1.2.0-beta.6** (current release) validates the MSIX update pipeline ahead of Microsoft Store submission. Windows distribution uses MSIX/App Installer with Windows-native updates — the app never replaces itself. The beta.5 → beta.6 in-place MSIX upgrade has been confirmed on Windows 11. See the [changelog](CHANGELOG.md) and [roadmap](ROADMAP.md) for what's new and what's next. +**v1.2.1** (current release) is the first public Microsoft Store release candidate. Windows distribution uses MSIX/App Installer with Windows-native updates — the app never replaces itself. See the [changelog](CHANGELOG.md) and [roadmap](ROADMAP.md) for what's new and what's next. ## Download @@ -64,11 +64,11 @@ Windows may show a SmartScreen warning because community builds are not code-sig **v1.2.0 update change:** In-app self-updating has been removed. OpenReader now uses **Windows-native updates** — the app never downloads or runs installers. -- **Existing v1.0.x and v1.1.x users must manually install a v1.2.0 beta MSIX once.** Future updates are handled by Windows App Installer or the Microsoft Store. +- **Existing v1.0.x and v1.1.x users** must manually install a v1.2.0+ MSIX once. Future updates are handled by the Microsoft Store or Windows App Installer. - **v1.2.0+ users:** Windows App Installer manages updates on launch and in the background. The app's Help → Check for Updates opens the GitHub Releases page in your browser. - Source builds should be updated with `git pull` and rebuilt locally. -> **⚠️ Production auto-updates via App Installer are not yet proven.** The beta.5 → beta.6 in-place MSIX upgrade has been validated locally with test signing, but the hosted App Installer workflow (automatic updates from a web endpoint) and Microsoft Store-managed updates require a Store submission. Until then, users update by downloading the latest MSIX from GitHub and installing manually. +> **ℹ️ Microsoft Store-managed updates** will provide automatic updates after Store approval. Until then, users update by downloading the latest MSIX from GitHub Releases and installing manually (Developer Mode required for unsigned packages). ## Features @@ -331,14 +331,13 @@ sudo pacman -S tesseract tesseract-data-eng - [x] Add App Installer template for Windows-managed updates - [x] Update GitHub Actions workflow to build MSIX - [x] Add architecture docs (`docs/windows-distribution.md`, `docs/updater-architecture.md`) -- [x] Validate MSIX install and in-place upgrade (beta.5 → beta.6, confirmed on Windows 11) -- [ ] **Store submission** — next milestone. Store signing replaces self-procured code-signing cert. +- [x] Validate MSIX install and in-place upgrade (confirmed on Windows 11) +- [x] Store submission — v1.2.1 is the first Microsoft Store release candidate ### Near-Term Items in active or planned development. - **Local AI summarization** — generate document summaries and extract key points using a local LLM (e.g. Ollama, llama.cpp); no data ever leaves your machine -- **Microsoft Store release** — signed MSIX distribution through the Microsoft Store, removing SmartScreen warnings and enabling Store-managed automatic updates - **Stronger sandboxing guidance** — documented approaches for running the app in an OS sandbox when opening documents from untrusted sources ### Long-Term Vision diff --git a/RELEASE.md b/RELEASE.md index ea899ba..c46371c 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -4,10 +4,10 @@ OpenReader uses semantic version tags to publish packaged builds. ## Version Source of Truth -- Development source keeps `__version__` in `main.py` as a `-dev` version. -- Packaged release builds inject the release version from the Git tag. -- Tags must use the format `vMAJOR.MINOR.PATCH`, for example `v1.2.0`. -- The injected runtime version removes the leading `v`, so `v1.2.0` becomes `__version__ = "1.2.0"` in packaged builds. +- `__version__` in `main.py` is the canonical source. Set it to the next release version. +- Tags must use the format `vMAJOR.MINOR.PATCH`, for example `v1.2.1`. +- The injected runtime version removes the leading `v`, so `v1.2.1` becomes `__version__ = "1.2.1"` in packaged builds. +- CI injects the tag version for release builds via `scripts/inject_version.py`. ## Release Architecture (v1.2.0+) @@ -45,8 +45,8 @@ no separate code-signing certificate is needed. 4. Create and push a semantic version tag: ```bash - git tag v1.2.0 - git push origin v1.2.0 + git tag v1.2.1 + git push origin v1.2.1 ``` 5. GitHub Actions runs `.github/workflows/release.yml`. @@ -97,9 +97,10 @@ curl https://api.github.com/repos/sparshsam/pdfreader-by-sparsh/releases/latest The MSIX package is currently unsigned. The distribution plan is: -1. **Short term** — Submit the unsigned MSIX to the Microsoft Store. The Store - signs the package automatically with its Store identity. -2. **Beta/sideloading** — Unsigned MSIX from GitHub Releases requires Windows +1. **Microsoft Store** — Submit the unsigned MSIX to the Microsoft Store. The Store + signs the package automatically with its Store identity. **v1.2.1 is the first + Store release candidate.** +2. **Sideloading** — Unsigned MSIX from GitHub Releases requires Windows Developer Mode. Local test-signing scripts are in `packaging/msix/`. 3. **No self-procured code-signing cert** — The Store handles production signing. Do not purchase a separate code-signing certificate. diff --git a/VERSIONING.md b/VERSIONING.md index c8f8271..38904cb 100644 --- a/VERSIONING.md +++ b/VERSIONING.md @@ -12,8 +12,8 @@ OpenReader follows [Semantic Versioning 2.0](https://semver.org/): The current version is tracked in the `__version__` variable in `main.py`. -- **Source builds** — use a `-dev` suffix (e.g., `1.2.0-beta.2-dev`). -- **Packaged releases** — the version is injected from the Git tag during the release workflow (see `scripts/inject_version.py`). +- **Source builds** — update `__version__` in `main.py` to match the target release. +- **Packaged releases** — the version can be overridden from the Git tag during the CI release workflow (see `scripts/inject_version.py`). ## Tag Format diff --git a/assets/AppIcon.iconset/icon_128x128.png b/assets/AppIcon.iconset/icon_128x128.png new file mode 100644 index 0000000..43169bf Binary files /dev/null and b/assets/AppIcon.iconset/icon_128x128.png differ diff --git a/assets/AppIcon.iconset/icon_128x128@2x.png b/assets/AppIcon.iconset/icon_128x128@2x.png new file mode 100644 index 0000000..9031452 Binary files /dev/null and b/assets/AppIcon.iconset/icon_128x128@2x.png differ diff --git a/assets/AppIcon.iconset/icon_16x16.png b/assets/AppIcon.iconset/icon_16x16.png new file mode 100644 index 0000000..2e129ec Binary files /dev/null and b/assets/AppIcon.iconset/icon_16x16.png differ diff --git a/assets/AppIcon.iconset/icon_16x16@2x.png b/assets/AppIcon.iconset/icon_16x16@2x.png new file mode 100644 index 0000000..a4cf89a Binary files /dev/null and b/assets/AppIcon.iconset/icon_16x16@2x.png differ diff --git a/assets/AppIcon.iconset/icon_256x256.png b/assets/AppIcon.iconset/icon_256x256.png new file mode 100644 index 0000000..9031452 Binary files /dev/null and b/assets/AppIcon.iconset/icon_256x256.png differ diff --git a/assets/AppIcon.iconset/icon_256x256@2x.png b/assets/AppIcon.iconset/icon_256x256@2x.png new file mode 100644 index 0000000..e6d642a Binary files /dev/null and b/assets/AppIcon.iconset/icon_256x256@2x.png differ diff --git a/assets/AppIcon.iconset/icon_32x32.png b/assets/AppIcon.iconset/icon_32x32.png new file mode 100644 index 0000000..a4cf89a Binary files /dev/null and b/assets/AppIcon.iconset/icon_32x32.png differ diff --git a/assets/AppIcon.iconset/icon_32x32@2x.png b/assets/AppIcon.iconset/icon_32x32@2x.png new file mode 100644 index 0000000..68a3d71 Binary files /dev/null and b/assets/AppIcon.iconset/icon_32x32@2x.png differ diff --git a/assets/AppIcon.iconset/icon_512x512.png b/assets/AppIcon.iconset/icon_512x512.png new file mode 100644 index 0000000..e6d642a Binary files /dev/null and b/assets/AppIcon.iconset/icon_512x512.png differ diff --git a/assets/AppIcon.iconset/icon_512x512@2x.png b/assets/AppIcon.iconset/icon_512x512@2x.png new file mode 100644 index 0000000..f1280cf Binary files /dev/null and b/assets/AppIcon.iconset/icon_512x512@2x.png differ diff --git a/assets/app-icon-source.png b/assets/app-icon-source.png new file mode 100755 index 0000000..c431a92 Binary files /dev/null and b/assets/app-icon-source.png differ diff --git a/assets/icon-150x150.png b/assets/icon-150x150.png new file mode 100644 index 0000000..39bd2c6 Binary files /dev/null and b/assets/icon-150x150.png differ diff --git a/assets/icon-310x150.png b/assets/icon-310x150.png new file mode 100644 index 0000000..d943989 Binary files /dev/null and b/assets/icon-310x150.png differ diff --git a/assets/icon-44x44.png b/assets/icon-44x44.png new file mode 100644 index 0000000..a1e859d Binary files /dev/null and b/assets/icon-44x44.png differ diff --git a/assets/icon-620x300.png b/assets/icon-620x300.png new file mode 100644 index 0000000..1145f7f Binary files /dev/null and b/assets/icon-620x300.png differ diff --git a/assets/icon-71x71.png b/assets/icon-71x71.png new file mode 100644 index 0000000..d0789be Binary files /dev/null and b/assets/icon-71x71.png differ diff --git a/assets/pdfreader_by_sparsh.ico b/assets/pdfreader_by_sparsh.ico index d9d1283..af082cd 100644 Binary files a/assets/pdfreader_by_sparsh.ico and b/assets/pdfreader_by_sparsh.ico differ diff --git a/docs/msix-update-validation.md b/docs/msix-update-validation.md index 169932a..d6bdfdc 100644 --- a/docs/msix-update-validation.md +++ b/docs/msix-update-validation.md @@ -1,6 +1,10 @@ -# MSIX Update Validation Guide +# MSIX Update Validation Guide (Archived) -**Purpose:** Validate that MSIX updates work correctly for OpenReader. +> **ℹ️ Historical record.** MSIX beta validation (beta.5 → beta.6) completed +> successfully. v1.2.1 is the first Microsoft Store release candidate — Store-managed +> updates replace sideloaded MSIX update testing. + +**Purpose:** Validate that MSIX updates work correctly for OpenReader (historical). **Test versions:** - v1.2.0-beta.5 (MSIX 1.2.0.5) — first installable MSIX baseline diff --git a/docs/store-submission-checklist.md b/docs/store-submission-checklist.md index ea7edeb..02bd37f 100644 --- a/docs/store-submission-checklist.md +++ b/docs/store-submission-checklist.md @@ -1,11 +1,11 @@ # Microsoft Store Submission Checklist — OpenReader -**Target version:** v1.2.0 stable (MSIX version `1.2.0.0`) +**Target version:** v1.2.1 stable (MSIX version `1.2.1.0`) **Store ID:** `9MXDVW2645LL` **PFN:** `SparshSam.OpenReader_yh0byntbzd2qw` **Status:** 🔜 Ready for submission (privacy policy published) **Privacy policy URL:** https://sparshsam.github.io/pdfreader-by-sparsh/privacy/ -**Upload artifact:** `OpenReader.msix` from v1.2.0 GitHub Release (built by release.yml workflow) +**Upload artifact:** `OpenReader.msix` from v1.2.1 GitHub Release (built by release.yml workflow) --- @@ -24,7 +24,7 @@ Get-AppxPackage SparshSam.OpenReader | Select Name, Version, PackageFamilyName ```text Name Version PackageFamilyName ---- ------- ----------------- -SparshSam.OpenReader 1.2.0.0 SparshSam.OpenReader_yh0byntbzd2qw +SparshSam.OpenReader 1.2.1.0 SparshSam.OpenReader_yh0byntbzd2qw ``` ### 1.2 Manifest Audit @@ -44,7 +44,7 @@ Select-Xml -Path .\msix-check\AppxManifest.xml -XPath "//*[local-name()='Identit - [ ] `` - [ ] `` -- [ ] Version is `1.2.0.0` +- [ ] Version is `1.2.1.0` - [ ] `OpenReader` - [ ] `Sparsh Sam` - [ ] Executable is `OpenReader.exe` @@ -111,7 +111,7 @@ Start-Process "OpenReader" 1. Navigate to **Partner Center** → OpenReader → **Packages** 2. Upload the **unsigned** `OpenReader.msix` from the GitHub Release 3. The Store will automatically sign the package with its Store identity -4. Set `1.2.0.0` as the version in Partner Center (must match manifest) +4. Set `1.2.1.0` as the version in Partner Center (must match manifest) 5. Submit for certification > **ℹ️** Upload the MSIX produced by the GitHub Actions release workflow directly. @@ -150,7 +150,7 @@ Start-Process "OpenReader" | **`runFullTrust` capability** | Store may ask why a desktop app needs full trust | Expected for Win32 desktop bridge apps. Document in submission notes: *"Desktop PDF reader using PySide6 — requires full trust for file system access and window management."* | | **App description claims** | Store may reject if claims are unrealistic | Keep description factual and shipping-feature-only. Remove roadmap items from Store description. | | **Unsplash/mock screenshots** | Store requires real app screenshots | Use actual app screenshots from `assets/` | -| **Version mismatch** | Upload rejected if manifest version ≠ Partner Center version | Verify `1.2.0.0` matches everywhere | +| **Version mismatch** | Upload rejected if manifest version ≠ Partner Center version | Verify `1.2.1.0` matches everywhere | | **Store ID reuse** | Cannot reuse Store ID for a different app | Reserved ID `9MXDVW2645LL` is tied to OpenReader — do not reassign | ### 4.2 Certification Notes for Submission @@ -203,7 +203,7 @@ Get-AppxPackage SparshSam.OpenReader | Select Name, Version, PackageFamilyName ```text Name Version PackageFamilyName ---- ------- ----------------- -SparshSam.OpenReader 1.2.0.0 SparshSam.OpenReader_yh0byntbzd2qw +SparshSam.OpenReader 1.2.1.0 SparshSam.OpenReader_yh0byntbzd2qw ``` ### 5.3 Functional Smoke Test @@ -249,7 +249,7 @@ Write-Host "Publisher check: $($matches.Count -gt 0 ? 'PASS' : 'FAIL')" - [ ] Prepare Winget manifest for `SparshSam.OpenReader` (optional, medium priority) - [ ] Monitor Partner Center certification report - [ ] After acceptance: test Store install on clean Windows VM -- [ ] After acceptance: test Store upgrade over existing sideloaded beta.6 +- [ ] After acceptance: test Store upgrade over existing sideloaded installation - [ ] Update `README.md` to reflect Store availability - [ ] Update `docs/windows-distribution.md` with Store channel details diff --git a/main.py b/main.py index 79cc06f..a1e4d5e 100644 --- a/main.py +++ b/main.py @@ -50,7 +50,7 @@ ) -__version__ = "1.2.0-beta.6-dev" +__version__ = "1.2.1" GITHUB_REPO = "sparshsam/pdfreader-by-sparsh" IPC_SERVER_NAME = "OpenReader-IPC" RECENT_FILES_MAX = 10 @@ -2784,12 +2784,6 @@ def _show_about(self): ver_label.setAlignment(Qt.AlignCenter) layout.addWidget(ver_label) - # MSIX update validation label (beta.4) - msix_label = QLabel("

" - "MSIX update validation — beta.6

") - msix_label.setAlignment(Qt.AlignCenter) - layout.addWidget(msix_label) - layout.addSpacing(8) # Description diff --git a/packaging/msix/AppInstaller.xml b/packaging/msix/AppInstaller.xml index bf14556..bd9ae0c 100644 --- a/packaging/msix/AppInstaller.xml +++ b/packaging/msix/AppInstaller.xml @@ -18,7 +18,7 @@ diff --git a/packaging/msix/AppxManifest.xml b/packaging/msix/AppxManifest.xml index 9fa6913..8196853 100644 --- a/packaging/msix/AppxManifest.xml +++ b/packaging/msix/AppxManifest.xml @@ -24,7 +24,7 @@ + Version="1.2.1.0" /> OpenReader diff --git a/packaging/msix/README.md b/packaging/msix/README.md index c4d9778..e429525 100644 --- a/packaging/msix/README.md +++ b/packaging/msix/README.md @@ -134,13 +134,11 @@ MSIX follows a `major.minor.patch.build` version scheme. | Git Tag | MSIX Version | Description | |---------|--------------|-------------| -| `v1.2.0-beta.1` | `1.2.0.0` | Beta release | -| `v1.2.0` | `1.2.0.0` | Stable release (same as beta) | -| `v1.2.1` | `1.2.1.0` | Patch release | +| `v1.2.0` | `1.2.0.0` | Initial MSIX release | +| `v1.2.1` | `1.2.1.0` | First Microsoft Store release candidate | The CI workflow automatically extracts the version from the Git tag and injects it -into the manifest as `{tag}.0` (padded to 4 parts). Source builds use `1.2.0.0` -as the development version. +into the manifest as `{tag}.0` (padded to 4 parts). ## Visual Assets @@ -217,22 +215,16 @@ identity exactly: `CN=E6186421-BF8A-47E0-A89C-0F513DFF91C0`. # 1. Install the test certificate (requires admin) .\packaging\msix\install-test-cert.ps1 -# 2. Install beta.3 MSIX (baseline) -# Double-click OpenReader.msix from v1.2.0-beta.3 release - -# 3. Install beta.4 MSIX (update test) -# Double-click OpenReader.msix from v1.2.0-beta.4 release -# Windows should perform an in-place upgrade +# 2. Install the MSIX package +# Double-click OpenReader.msix from the target release ``` ### What to Verify -After installing the beta.3 MSIX and updating to beta.4: - -- [ ] MSIX installs without Developer Mode -- [ ] Update is **in-place** (not side-by-side) -- [ ] Package Family Name remains `SparshSam.OpenReader_yh0byntbzd2qw` -- [ ] About dialog shows the beta.4 release label +- [ ] MSIX installs without Developer Mode (when test-signed) +- [ ] Package Family Name is `SparshSam.OpenReader_yh0byntbzd2qw` +- [ ] New branding appears in Start Menu and taskbar +- [ ] About dialog shows the correct version - [ ] Previous settings (theme, recent files) persist - [ ] No duplicate application entries in Start Menu or Apps list - [ ] PDF file associations remain intact diff --git a/requirements.txt b/requirements.txt index a4df59e..12d8ac9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ PySide6==6.11.1 PyMuPDF==1.27.2.3 pyinstaller==6.20.0 +Pillow>=10.0.0 diff --git a/tools/create_icon.py b/tools/create_icon.py index 9ea47dd..13a3986 100644 --- a/tools/create_icon.py +++ b/tools/create_icon.py @@ -1,96 +1,121 @@ -from pathlib import Path -import argparse -import struct -import sys +#!/usr/bin/env python3 +"""Generate all app icon assets from a source PNG. -from PySide6.QtCore import QBuffer, QByteArray, QIODevice, QPointF, QRectF, Qt -from PySide6.QtGui import QColor, QFont, QImage, QLinearGradient, QPainter, QPainterPath, QPen -from PySide6.QtWidgets import QApplication +Usage: + python tools/create_icon.py --source [--ico assets/icon.ico] +Generates: + - Windows .ico (16, 24, 32, 48, 64, 128, 256 px) + - MSIX-branded PNGs (44x44, 71x71, 150x150, 310x150, 620x300) + - macOS .iconset directory (for iconutil -> .icns) +""" -ROOT = Path(__file__).resolve().parents[1] -ASSETS = ROOT / "assets" -ICON_PATH = ASSETS / "pdfreader_by_sparsh.ico" - - -def make_png(size: int) -> bytes: - image = QImage(size, size, QImage.Format_ARGB32) - image.fill(Qt.transparent) +import argparse +import io +import struct +import sys +from pathlib import Path - painter = QPainter(image) - painter.setRenderHint(QPainter.Antialiasing) +from PIL import Image - scale = size / 256 - shadow = QPainterPath() - shadow.addRoundedRect(QRectF(42 * scale, 24 * scale, 158 * scale, 210 * scale), 22 * scale, 22 * scale) - painter.fillPath(shadow.translated(7 * scale, 8 * scale), QColor(0, 0, 0, 44)) +def load_source(source: Path) -> Image.Image: + """Load source PNG, ensure RGBA mode.""" + img = Image.open(source) + return img.convert("RGBA") - page = QPainterPath() - page.addRoundedRect(QRectF(38 * scale, 20 * scale, 162 * scale, 214 * scale), 22 * scale, 22 * scale) - painter.fillPath(page, QColor("#f8fafc")) - painter.setPen(QPen(QColor("#cbd5e1"), max(1, int(3 * scale)))) - painter.drawPath(page) - fold = QPainterPath() - fold.moveTo(158 * scale, 20 * scale) - fold.lineTo(200 * scale, 62 * scale) - fold.lineTo(166 * scale, 72 * scale) - fold.quadTo(158 * scale, 72 * scale, 158 * scale, 64 * scale) - fold.closeSubpath() - painter.fillPath(fold, QColor("#e2e8f0")) +def make_png_square(img: Image.Image, size: int) -> Image.Image: + """Resize to a square, cropping centered if needed.""" + w, h = img.size + if w != h: + # Crop to center square first + min_dim = min(w, h) + left = (w - min_dim) // 2 + top = (h - min_dim) // 2 + img = img.crop((left, top, left + min_dim, top + min_dim)) + return img.resize((size, size), Image.LANCZOS) - accent = QLinearGradient(QPointF(54 * scale, 142 * scale), QPointF(184 * scale, 214 * scale)) - accent.setColorAt(0, QColor("#ef4444")) - accent.setColorAt(1, QColor("#b91c1c")) - painter.setBrush(accent) - painter.setPen(Qt.NoPen) - painter.drawRoundedRect(QRectF(54 * scale, 142 * scale, 132 * scale, 58 * scale), 16 * scale, 16 * scale) - painter.setPen(QPen(QColor("#94a3b8"), max(1, int(7 * scale)), Qt.SolidLine, Qt.RoundCap)) - for y in (82, 104, 126): - painter.drawLine(QPointF(62 * scale, y * scale), QPointF(156 * scale, y * scale)) +def make_png_rect(img: Image.Image, width: int, height: int) -> Image.Image: + """Resize to a rectangle, cropping centered if needed to match aspect ratio.""" + src_w, src_h = img.size + target_ratio = width / height + src_ratio = src_w / src_h - font = QFont("Segoe UI", max(10, int(44 * scale)), QFont.Bold) - painter.setFont(font) - painter.setPen(QColor("white")) - painter.drawText(QRectF(54 * scale, 137 * scale, 132 * scale, 66 * scale), Qt.AlignCenter, "S") + if abs(src_ratio - target_ratio) > 0.01: + # Crop source to target ratio + if src_ratio > target_ratio: + # Source is wider, crop sides + new_w = int(src_h * target_ratio) + left = (src_w - new_w) // 2 + img = img.crop((left, 0, left + new_w, src_h)) + else: + # Source is taller, crop top/bottom + new_h = int(src_w / target_ratio) + top = (src_h - new_h) // 2 + img = img.crop((0, top, src_w, top + new_h)) - small_font = QFont("Segoe UI", max(7, int(22 * scale)), QFont.Bold) - painter.setFont(small_font) - painter.setPen(QColor("#991b1b")) - painter.drawText(QRectF(58 * scale, 204 * scale, 118 * scale, 24 * scale), Qt.AlignCenter, "PDF") + return img.resize((width, height), Image.LANCZOS) - painter.end() - data = QByteArray() - buffer = QBuffer(data) - buffer.open(QIODevice.WriteOnly) - image.save(buffer, "PNG") - return bytes(data) +def write_ico(img: Image.Image, path: Path, sizes=(16, 24, 32, 48, 64, 128, 256)): + """Write a multi-resolution .ico file from source image.""" + path.parent.mkdir(parents=True, exist_ok=True) + pngs = [] + for size in sizes: + resized = make_png_square(img, size) + buf = io.BytesIO() + resized.save(buf, "PNG") + pngs.append(buf.getvalue()) -def write_ico(path: Path, sizes=(16, 24, 32, 48, 64, 128, 256)): - pngs = [(size, make_png(size)) for size in sizes] header = struct.pack("= 256 else size + height = 0 if size >= 256 else size + directory.extend( + struct.pack( + " {path} ({len(sizes)} sizes)") + + +def write_msix_assets(img: Image.Image, output_dir: Path): + """Write MSIX brand images at the sizes the manifest declares.""" + manifest_sizes = [ + ("icon-44x44.png", 44, 44), + ("icon-71x71.png", 71, 71), + ("icon-150x150.png", 150, 150), + ("icon-310x150.png", 310, 150), + ("icon-620x300.png", 620, 300), + ] + + output_dir.mkdir(parents=True, exist_ok=True) + for name, w, h in manifest_sizes: + path = output_dir / name + if w == h: + resized = make_png_square(img, w) + else: + resized = make_png_rect(img, w, h) + resized.save(path, "PNG") + print(f" MSIX asset -> {path} ({w}x{h})") + + +def write_macos_iconset(img: Image.Image, output_dir: Path): + """Write macOS .iconset directory (for iconutil -> .icns on macOS).""" icon_sizes = { "icon_16x16.png": 16, "icon_16x16@2x.png": 32, @@ -103,22 +128,51 @@ def write_png_iconset(path: Path): "icon_512x512.png": 512, "icon_512x512@2x.png": 1024, } - path.mkdir(parents=True, exist_ok=True) - for file_name, size in icon_sizes.items(): - image = QImage() - image.loadFromData(make_png(size), "PNG") - image.save(str(path / file_name), "PNG") + output_dir.mkdir(parents=True, exist_ok=True) + for name, size in icon_sizes.items(): + resized = make_png_square(img, size) + path = output_dir / name + resized.save(path, "PNG") + print(f" macOS iconset -> {output_dir}/ ({len(icon_sizes)} files)") -if __name__ == "__main__": - app = QApplication.instance() or QApplication(sys.argv) - parser = argparse.ArgumentParser(description="Create app icon assets.") - parser.add_argument("--ico", default=str(ICON_PATH), help="Path for the Windows .ico output.") - parser.add_argument("--png-iconset", help="Optional macOS .iconset directory to create.") + +def main(): + parser = argparse.ArgumentParser(description="Create app icon assets from source PNG.") + parser.add_argument("--source", required=True, help="Path to source PNG (1024x1024 recommended)") + parser.add_argument("--ico", default="assets/pdfreader_by_sparsh.ico", help="Output .ico path") + parser.add_argument("--msix-dir", default="assets", help="Output directory for MSIX PNGs") + parser.add_argument("--macos-iconset", default="assets/AppIcon.iconset", help="Output macOS .iconset dir") args = parser.parse_args() - write_ico(Path(args.ico)) - print(args.ico) - if args.png_iconset: - write_png_iconset(Path(args.png_iconset)) - print(args.png_iconset) + source_path = Path(args.source) + if not source_path.exists(): + print(f"ERROR: Source not found: {source_path}") + sys.exit(1) + + print(f"Loading source: {source_path}") + img = load_source(source_path) + print(f" Source size: {img.size}") + + root = Path(__file__).resolve().parents[1] + + # 1. Windows .ico + ico_path = root / args.ico + print(f"\nGenerating Windows .ico...") + write_ico(img, ico_path) + + # 2. MSIX brand PNGs + msix_dir = root / args.msix_dir + print(f"\nGenerating MSIX brand assets...") + write_msix_assets(img, msix_dir) + + # 3. macOS iconset + iconset_dir = root / args.macos_iconset + print(f"\nGenerating macOS iconset...") + write_macos_iconset(img, iconset_dir) + + print(f"\n✅ All icon assets generated from {source_path.name}") + + +if __name__ == "__main__": + main() diff --git a/tools/create_msix_placeholder_pngs.py b/tools/create_msix_placeholder_pngs.py index 856a60f..58b5904 100644 --- a/tools/create_msix_placeholder_pngs.py +++ b/tools/create_msix_placeholder_pngs.py @@ -1,39 +1,62 @@ -"""Generate minimal placeholder PNGs for MSIX packaging. +"""Generate MSIX brand image assets from the project's source icon. -Creates 1x1 pixel PNG files at specified sizes. These are real valid PNGs -that MakeAppx accepts. Replace with proper icon assets before production. +Creates MSIX-required PNGs at the sizes declared in AppxManifest.xml. +Uses the project's source icon for real brand images instead of placeholders. """ -import struct -import zlib + import sys from pathlib import Path +from PIL import Image -def create_png(filepath: Path, width: int, height: int): - """Create a minimal valid 1x1 pixel PNG.""" - # PNG signature - signature = b'\x89PNG\r\n\x1a\n' - - # IHDR chunk: width, height, bit_depth=8, color_type=2(RGB) - ihdr_data = struct.pack('>IIBBBBB', width, height, 8, 2, 0, 0, 0) - ihdr_crc = zlib.crc32(b'IHDR' + ihdr_data) & 0xffffffff - ihdr = struct.pack('>I', 13) + b'IHDR' + ihdr_data + struct.pack('>I', ihdr_crc) - # IDAT chunk: minimal compressed pixel data (1 pixel red) - raw_data = b'\x00' + b'\xff\x00\x00' # filter byte + RGB red - compressed = zlib.compress(raw_data) - idat_crc = zlib.crc32(b'IDAT' + compressed) & 0xffffffff - idat = struct.pack('>I', len(compressed)) + b'IDAT' + compressed + struct.pack('>I', idat_crc) +def make_png_square(img: Image.Image, size: int) -> Image.Image: + w, h = img.size + if w != h: + min_dim = min(w, h) + left = (w - min_dim) // 2 + top = (h - min_dim) // 2 + img = img.crop((left, top, left + min_dim, top + min_dim)) + return img.resize((size, size), Image.LANCZOS) - # IEND chunk - iend_crc = zlib.crc32(b'IEND') & 0xffffffff - iend = struct.pack('>I', 0) + b'IEND' + struct.pack('>I', iend_crc) - filepath.parent.mkdir(parents=True, exist_ok=True) - filepath.write_bytes(signature + ihdr + idat + iend) +def make_png_rect(img: Image.Image, width: int, height: int) -> Image.Image: + src_w, src_h = img.size + target_ratio = width / height + src_ratio = src_w / src_h + if abs(src_ratio - target_ratio) > 0.01: + if src_ratio > target_ratio: + new_w = int(src_h * target_ratio) + left = (src_w - new_w) // 2 + img = img.crop((left, 0, left + new_w, src_h)) + else: + new_h = int(src_w / target_ratio) + top = (src_h - new_h) // 2 + img = img.crop((0, top, src_w, top + new_h)) + return img.resize((width, height), Image.LANCZOS) def main(): + root = Path(__file__).resolve().parents[1] + source_path = root / "assets" / "pdfreader_by_sparsh.ico" + output_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else root / "assets" + + # Load from .ico or find source PNG + if source_path.exists(): + img = Image.open(source_path) + # .ico loads first frame; get full size + img = img.convert("RGBA") + else: + # Fallback: try a source PNG + png_path = root / "assets" / "icon-150x150.png" + if png_path.exists(): + img = Image.open(png_path).convert("RGBA") + else: + print("ERROR: No source icon found. Run tools/create_icon.py first.") + sys.exit(1) + + output_dir.mkdir(parents=True, exist_ok=True) + sizes = [ ("icon-44x44.png", 44, 44), ("icon-150x150.png", 150, 150), @@ -42,15 +65,14 @@ def main(): ("icon-620x300.png", 620, 300), ] - output_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("assets") - for name, w, h in sizes: path = output_dir / name - if path.exists(): - print(f"SKIP {path} (exists)") + if w == h: + resized = make_png_square(img, w) else: - create_png(path, w, h) - print(f"CREATED {path} ({w}x{h})") + resized = make_png_rect(img, w, h) + resized.save(path, "PNG") + print(f"GENERATED {path} ({w}x{h})") if __name__ == "__main__":