diff --git a/.gitignore b/.gitignore index 1c2e2b27e5..32259a170c 100644 --- a/.gitignore +++ b/.gitignore @@ -121,3 +121,5 @@ cli/npm/package-lock.json # PAGX verify artifacts *.layout.xml +# Local-only helper scripts (not part of the project) +pagx/wechat/script/copy-to-cocraft.js diff --git a/include/pagx/ImageResourceProvider.h b/include/pagx/ImageResourceProvider.h new file mode 100644 index 0000000000..292283f363 --- /dev/null +++ b/include/pagx/ImageResourceProvider.h @@ -0,0 +1,65 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include +#include + +namespace tgfx { +class Image; +} + +namespace pagx { + +/** + * Provides pre-decoded image resources to the rendering pipeline. Platform-specific implementations + * can manage their own caching, eviction, and progressive loading strategies without coupling + * decoded image state to the document model layer. + * + * The renderer queries this provider during layer building. When resolveImage() returns non-null, + * the renderer uses that image directly and skips all codec-based decoding paths (embedded data, + * data URI, file path). When it returns nullptr, the renderer falls back to the standard decoding + * chain. + * + * Thread safety: the provider is accessed only on the render thread (inside draw()/flush()), so + * implementations do not need internal synchronization unless they share state with other threads. + */ +class ImageResourceProvider { + public: + virtual ~ImageResourceProvider() = default; + + /** + * Resolves a pre-decoded image for the given external file path. The implementation decides + * which quality level to return (full, thumbnail, or nullptr for fallback). + * @param filePath the external file path as declared in the PAGX document's Image node. + * @return A tgfx::Image ready for rendering, or nullptr to signal the renderer should decode + * from embedded data / file path. + */ + virtual std::shared_ptr resolveImage(const std::string& filePath) = 0; + + /** + * Returns true if the provider currently holds any decoded image (full or thumbnail) for the + * given path. Used by getExternalFilePaths() to exclude paths that already have a decoded + * counterpart, preventing redundant network fetches. + * @param filePath the external file path to check. + */ + virtual bool hasImage(const std::string& filePath) const = 0; +}; + +} // namespace pagx diff --git a/include/pagx/PAGXDocument.h b/include/pagx/PAGXDocument.h index 65b0d9ca20..a090d28d5b 100644 --- a/include/pagx/PAGXDocument.h +++ b/include/pagx/PAGXDocument.h @@ -24,6 +24,7 @@ #include #include #include "pagx/FontConfig.h" +#include "pagx/ImageResourceProvider.h" #include "pagx/nodes/Animation.h" #include "pagx/nodes/Layer.h" #include "pagx/nodes/Node.h" @@ -33,6 +34,8 @@ namespace pagx { class LayoutContext; class PAGScene; +class Image; +class ImagePattern; /** * PAGXDocument is the root container for a PAGX document. @@ -126,7 +129,9 @@ class PAGXDocument : public Node { /** * Returns a list of external file paths referenced by Image nodes or external composition layers - * that have no embedded data. Data URIs (paths starting with "data:") are excluded. + * that have no embedded data. Data URIs (paths starting with "data:") are excluded. Image nodes + * for which the current ImageResourceProvider already holds a decoded counterpart are also + * excluded so the same resource is not fetched twice. */ std::vector getExternalFilePaths() const; @@ -143,6 +148,17 @@ class PAGXDocument : public Node { */ bool loadFileData(const std::string& filePath, std::shared_ptr data); + /** + * Sets the image resource provider used by the rendering pipeline to resolve pre-decoded images. + * When set, getExternalFilePaths() consults the provider to exclude paths that already have a + * decoded counterpart. The renderer queries the provider during layer building via + * resolveImage(). + * + * Passing nullptr removes the provider; the renderer will use only embedded data / file paths. + * @param provider the platform-specific image resource provider, or nullptr to remove. + */ + void setImageResourceProvider(std::shared_ptr provider); + /** * Executes auto layout on the document, positioning layers according to their layout * constraints. Must be called before rendering or font embedding. Re-running layout on an @@ -234,6 +250,9 @@ class PAGXDocument : public Node { private: PAGXDocument() = default; + + const std::vector& findLayersByImageFilePath(const std::string& imageFilePath); + // Recursive layout worker. visited holds the documents on the current ancestor path so an // externalDoc cycle built directly through the API (bypassing loadFileData's own chain guard) // is detected and stops the recursion instead of overflowing the stack. @@ -247,6 +266,7 @@ class PAGXDocument : public Node { void registerLiveScene(const std::shared_ptr& scene); void unregisterLiveScene(PAGScene* scene); + std::shared_ptr _imageResourceProvider = nullptr; FontConfig fontConfig; bool layoutApplied = false; std::unordered_map nodeMap = {}; @@ -257,11 +277,20 @@ class PAGXDocument : public Node { // does not keep PAGScene alive; expired entries are pruned during notifyChange. std::vector> liveScenes = {}; + // Lazily built index of Image node filePath -> pagx Layer list, used by + // findLayersByImageFilePath(). Built on first query; invalidated by notifyChange() since edits + // may alter the tree topology or Image node filePath values. Also freed when the document is + // destroyed (on the next parsePAGX() call). + std::unordered_map> layersByImageFilePath = {}; + bool layersByImageFilePathBuilt = false; + friend class PAGXImporter; friend class PAGXExporter; friend class TextLayoutContext; friend class PAGScene; friend class PAGComposition; + friend class LayerBuilderContext; + friend class LayerBuilderSession; }; } // namespace pagx diff --git a/include/pagx/nodes/Image.h b/include/pagx/nodes/Image.h index 771e2f9081..7a510d44e5 100644 --- a/include/pagx/nodes/Image.h +++ b/include/pagx/nodes/Image.h @@ -27,12 +27,13 @@ namespace pagx { /** * Image represents an image resource that can be referenced by other nodes. The image source can - * be a file path, a URL, or a base64-encoded data URI. + * be a file path, a URL, or a base64-encoded data URI. Platform-specific decoded images are + * managed externally via ImageResourceProvider, not stored on this node. */ class Image : public Node { public: /** - * Image binary data (decoded from base64). + * Image binary data (decoded from base64 or loaded via loadFileData). */ std::shared_ptr data = nullptr; diff --git a/pagx/wechat/.gitignore b/pagx/wechat/.gitignore new file mode 100644 index 0000000000..a2b1a34d3e --- /dev/null +++ b/pagx/wechat/.gitignore @@ -0,0 +1,15 @@ +# Build outputs +wasm-mt/ +wasm/ +dist/ + +# Build cache +script/build-pagx-viewer/ +script/wasm-mt/ +script/.build.lock +.*.md5 +package-lock.json +ts/wasm +types/ +lib/ +docs/ diff --git a/pagx/wechat/CHANGELOG.md b/pagx/wechat/CHANGELOG.md new file mode 100644 index 0000000000..b149aa8838 --- /dev/null +++ b/pagx/wechat/CHANGELOG.md @@ -0,0 +1,294 @@ +# Changelog + +## v1.9.6 + +### 变更 + +- 优化首帧图片加载策略 + +--- + +## v1.9.5 + +### 变更 + +- 对小程序已弃用的接口做兼容处理 + +--- + +## v1.9.4 + +### 变更 + +- 修复 ios 小程序缩放设计稿时字体颜色渐变的问题 + +--- + +## v1.9.3 + +### 新增 + +- `View.setImageOriginalSize(filePath, width, height)`:向 SDK 声明外部图片的原始像素尺寸(全分辨率), + 用于校正 new-format PAGX 的 ImagePattern 变换矩阵。当宿主下载缩略图后通过 + `attachNativeImage()` 注入时,SDK 根据原始尺寸与缩略图尺寸的比例修正填充偏移,避免图案错位。 + 应在 `parsePAGX()` 之后、`buildLayers()` / `attachNativeImage()` 之前调用。 + +--- + +## v1.9.2 + +### 变更 + +- 修复渐变效果显示异常的问题 + +--- + +## v1.9.1 + +### 变更 + +- TypeScript 类型完善:`PAGX` interface 显式声明了 `View`(`typeof View`)字段。 + 先前依赖 `TGFX` 的索引签名(`[key: string]: any`)兜底,IDE 无法补全;现在 + `await PAGXInit({...})` 拿到的 `module` 上调用 `module.View.init(...)` 可获得完整的 + 参数与返回值类型补全 +- 仅类型层面补充,对外运行时 API 与 v1.9 保持一致 + +--- + +## v1.9 + +### 新增 + +#### 渐进式图片加载(宿主端解码 + 分级缓存) + +新增一整套 API,把 webp/png/jpeg 解码从 wasm 主线程下放到小程序原生解码器,并在 SDK 内提供 +缩略图 + 高清图双桶 LRU 缓存,业务层只需关心"下载哪张图"。 + +- `View.attachNativeImage(filePath, nativeImage, quality)`:把宿主解码好的图片 + (`OffscreenCanvas` 等)按质量等级(`ImageQuality.Thumbnail` / `ImageQuality.Full`) + 挂到对应 Image 节点。下一次 `draw()` 时上传成 GPU 常驻 backend texture,宿主侧的 + `OffscreenCanvas` 调用返回后即可释放。`Full` 桶超过预算时按 LRU 驱逐并触发 + `onTextureEvict`;`Thumbnail` 桶满时静默驱逐最旧条目,作为 `Full` 被驱逐时的兜底, + 保证图层不变白 +- `ImageQuality` 枚举:`Thumbnail = 0` / `Full = 1`,与 C++ 端 `pagx::ImageQuality` 对齐 +- `View.setTextureEventHandler(handler)`:注册纹理生命周期回调 + - `onTextureRequest(filePath)`:渲染时若引用某 `filePath` 但既无 Full 纹理也无在飞请求, + SDK 主动通知业务层去拉,业务层走 `attachNativeImage(path, canvas, Full)` 完成投递 + - `onTextureEvict(filePaths[])`:本帧被 LRU 扫掉的 Full 纹理列表,业务可顺带释放自己缓存 + - 传 `null` 清除已注册 handler;handler 跨 `parsePAGX()` 持久存在 +- `View.isFullBudgetSaturated()`:查询 Full 桶是否已越过硬上限。渐进升级循环每轮顶部 + 检查它,为 `true` 则跳过新一批投递(响应 `onTextureRequest` 的 1:1 替换始终安全) +- `View.getImageBounds(filePaths)`:查询每个 `filePath` 在 root-space(canvas 像素)下的 + bounds,含 `unionBounds`(视口相交判断)+ `largestBounds`(焦距评分)。需在 + `buildLayers()` 之后调用,首次调用因 tgfx lazy 计算 localBounds 较重,建议放到首帧之后 + 的 idle 时机 +- `View.getImageMetadata()`:列出每个外部图片的原始尺寸 + 每处 `ImagePattern` 用法 + (`nodeWidth/Height`、`scaleMode`、`scaleFactor`),业务可据此挑合适的缩略图档位与 + 显示比例,无需重新解析 PAGX XML。需在 `parsePAGX()` 之后调用 + +#### 宿主端图片解码工具 + +- `View.decodeImageFromPath(filePath)`:把本地临时文件 / URL(小程序可解析)解码到 + `OffscreenCanvas`。解码运行在小程序原生 webp/png/jpeg 解码线程,多张图可并发 +- `View.decodeImageFromBytes(bytes, hint?)`:把已下载的字节流落到 `wx.env.USER_DATA_PATH` + 下的临时文件后调用 `decodeImageFromPath`,返回 `OffscreenCanvas`,调用结束后自动清理临时文件 + +#### 其它新增 API + +- `View.setSnapshotEnabled(enabled)`:fitSnapshot 快路径开关。默认 `true`,会在 zoom + ≤ 1.02 时直接 blit 缓存的 fit-to-canvas 快照,跳过完整 displayList 渲染。关闭后每帧 + 强制完整渲染,牺牲性能换取首帧清晰度与渐进式加载的实时反馈 +- `View.setGestureActive(active)`:手势冻结快路径。pan/zoom 开始时传 `true`、结束时传 + `false`。`active=true` 时 native 侧会 readback 当前 surface 作为 cachedSnapshot +- `View.resetForFreshCapture()`:丢弃 `parsePAGX` 与手势 state 初始化之间产生的 + cached / fit 快照,并复位首帧标志,让 `isFirstFrameRendered()` 反映 init 后的渲染状态。 + 在新文档加载并完成 `applyGestureState()` 后调用 + +#### 渲染卡顿预检 + +- `CheckPagx(pagxData)`:异步评估 PAGX 文件在当前设备上的渲染卡顿风险。 + 作为**独立模块**(`lib/pagx-check.js`)发布,不包含在 `pagx-viewer.js` 中, + 不依赖 WASM 初始化或 WebGL 上下文。使用方式: + `const { CheckPagx } = require('./utils/pagx-check')` + - SDK 内部根据五条独立失效路径(BgBlur 嵌套、Path 几何量、大画布 × 元素密度、BgBlur 数量、 + Layer 数量)对文件评分,并按设备性能等级(`wx.getDeviceBenchmarkInfo`)动态收紧阈值 + - 返回 `Promise<{ score, benchmarkLevel, deviceTier, platform }>` + - 渲染建议:Android 平台 `score ≥ 65`、其他平台(iOS 等)`score ≥ 75` 可正常渲染 + - 设备档位(`'high' | 'mid' | 'low' | 'unknown'`)由 `benchmarkLevel` 与 `platform` 联合判定 + +### 变更 + +- `package.json` `typings` 仍指向 `types/pagx/wechat/ts/pagx.d.ts`,新导出 + `ImageQuality`、`TextureEventHandler` 类型可直接 `import` 使用 + +--- + +## v1.8.2 + +### 变更 + +- 版本号更新,用于在 v1.8 发布后重新发布小程序包;对外 API 与 v1.8 保持一致。 + +--- + +## v1.8 + +### 破坏性变更 + +- **删除 `View.zoomBy(scaleDelta, focalX, focalY)`**。该 API 在 v1.7 实现为 + 增量式 zoom(每帧 `newZoom = currentZoom × scaleDelta`),但与真实 pinch + 手势的"基于会话起点的累计 scale"模型不匹配——连续调用时浮点累积 + focal + 不在视野中心导致的几何挤压,会让画面在缩放过程中持续平移。请改用 v1.8 新增 + 的 pinchBy 系列 API。 + +### 新增 + +- `View.beginPinchGesture()` / `View.pinchBy(scale, focalX, focalY)` / + `View.endPinchGesture()`:基于手势会话的绝对式 pinch zoom,参考 iOS + `UIPinchGestureRecognizer` 设计。 + - **手势模式**:`beginPinchGesture()` 抓 snapshot,后续每次 `pinchBy(scale)` + 都基于 snapshot 重算 zoom/offset,无累积、无漂移;`endPinchGesture()` 清 + snapshot + - **单次模式**:未调用 `beginPinchGesture()` 时直接调 `pinchBy(scale, x, y)`, + 基于当前 view 状态做一次绝对 zoom,等价 v1.7 的 zoomBy 单次调用语义。适用 + 于鼠标滚轮、双击、按钮"+/-"等离散输入 + +### 迁移指南 + +| v1.7 写法 | v1.8 写法 | +|---|---| +| `view.zoomBy(perFrameRatio, focalX, focalY)` 每帧调用(手势)| `beginPinch → pinchBy(cumulativeScale, focalX, focalY) × N → endPinch` | +| `view.zoomBy(1.1, mouseX, mouseY)` 单次调用 | `view.pinchBy(1.1, mouseX, mouseY)`(无 begin/end) | + +`updateZoomScaleAndOffset / panBy / setPadding / getViewportInfo` 完全不变。 + +--- + +## v1.7 + +### 新增 + +- 新增 `View.setPadding(logicalPx)` API:配置单 Frame 预览的单边预留留白(逻辑像素,默认 0) + - 影响 `panBy` / `zoomBy` 内部的 pan 上限与 zoom 下界,实现"文档加 padding 不会缩到比 canvas 还小" + - 默认值 0 保持与历史版本兼容,不调用此接口的业务方行为不变 +- 新增 `View.panBy(deltaX, deltaY)` API:增量 pan + 自动 clamp + 越界报告 + - 入参为 canvas 物理像素增量 + - 返回 `{ remainingX, remainingY }` —— 未消耗的 pan 量。非零意味着方向越界,业务层据此触发翻页 / 弹性反馈等 UI + - `panBy(0, 0)` 是合法的"重新 clamp"调用,可在外部直接修改 zoom 后用来收回越界的 offset +- 新增 `View.zoomBy(scaleDelta, focalX, focalY)` API:增量 zoom + 锚点偏移 + 自动 clamp + - 内部按 zoom-around-point 公式更新 offset,保证捏合中心稳定 + - 自动应用 zoom 下界(基于 `setPadding` 配置):缩到"文档+padding"等于 canvas 时为底 + - zoom 完成后再次 clamp offset,避免 zoom 改变后 offset 越界 +- 新增 `View.getViewportInfo()` API:一次性获取所有视口状态 + - 返回字段:`{ zoom, offsetX, offsetY, canvasWidth, canvasHeight, contentWidth, contentHeight, fitScale, atLeft, atRight, atTop, atBottom }` + - `atLeft/atRight/atTop/atBottom` 表示文档边缘是否已贴到 canvas 边缘(0.5px 容差) + - 替代多次调用 `contentWidth()` / `contentHeight()` / `getContentTransform()` 等 + +### 变更 + +- `View.updateZoomScaleAndOffset(zoom, offsetX, offsetY)` 仍保留,但内部会同步更新 SDK 缓存的 zoom/offset +- 推荐手势驱动的 viewport 修改改用 `panBy` / `zoomBy`,由 SDK 统一处理 clamp 策略;只有需要绝对跳转或自带 clamp 的调用方才直接用 `updateZoomScaleAndOffset` + +### 兼容性 + +- 完全向后兼容:未调用 `setPadding` 的业务方,所有行为与 v1.6 一致 +- 业务方可以渐进迁移:原有 `updateZoomScaleAndOffset` 调用无需修改 + +--- + +## v1.6 + +### 修复 + +- 解决加载部分 pagx 文件时 PAGXView 初始化失败的问题 + +--- + +## v1.4 + +### 新增 + +- 新增 `View.getNodePosition(nodeId)` API,支持通过节点 ID 查询节点相对于画布的位置坐标 + - 返回值:`{ found: boolean, x: number, y: number }` + - 用于获取 PAGX 文件中特定节点的位置信息,可用于交互定位、节点高亮等场景 +- 新增 `NodePosition` 类型定义 + +### 优化 + +- 优化渲染性能 + +### 变更 + +- WASM 文件大小从 ~941KB 增长至 ~954KB + +--- + +## v1.3 + +> 本次更新为内部渲染模块优化,**对外 API 无任何变化,调用方无需修改代码**,仅需更新 WASM 文件即可。 + +### 内部优化 + +- 新增内部模块 `ImagePatternMatrixCalculator`,支持 CoCraft 导出的 PAGX 文件中 ImagePattern 图案填充的变换矩阵自动计算(STRETCH / FIT / FILL / TILE 四种缩放模式) +- 在 `buildLayers()` 阶段自动完成矩阵解算,对调用方完全透明 + +### 变更 + +- WASM 文件大小从 ~937KB 增长至 ~941KB(+3.76KB) + +--- + +## v1.2 + +### 新增 + +- 新增三步加载流程,支持含外部图片资源的 PAGX 文件: + - `View.parsePAGX(data)` — 仅解析 PAGX 文件,不构建层树 + - `View.getExternalFilePaths()` — 获取外部文件引用路径列表,根据 path 自行下载图片 + - `View.loadFileData(filePath, fileData)` — 加载外部文件数据到对应 Image 节点 + - `View.buildLayers()` — 构建层树,完成渲染内容准备 +- 新增 `View.getContentTransform()` API,返回坐标转换参数(`boundsOriginX`、`boundsOriginY`、`fitScale`、`centerOffsetX`、`centerOffsetY`),用于评论浮层定位 +- 新增 `View.setBoundsOrigin(x, y)` API,手动设置 boundsOrigin,覆盖 PAGX 文件中的值 +- 新增 `ContentTransform` 类型定义 + +### 变更 + +- `View.onZoomEnd()` 标记为已弃用(`@deprecated`),内部已自动检测缩放结束 +- WASM 文件大小从 ~746KB 增长至 ~937KB + +--- + +## v1.1 + +### 新增 + +- 新增 `View.registerFonts(fontData, emojiFontData)` API,支持注册自定义字体和 Emoji 字体 +- 新增 `View.isFirstFrameRendered()` API,查询首帧是否已渲染 +- `PAGXViewOptions` 新增 `onFirstFrame` 回调,在首帧渲染完成时触发 + +### 变更 + +- 新增 `View.registerFonts()` 要在 `View.loadPAGX()` 前注册自定义字体 +- CDN 域名从 `pag.io` 变更为 `pag.qq.com` +- WASM 文件大小从 ~470KB 增长至 ~746KB + +--- + +## v1.0 + +首个发布版本。 + +### 功能 + +- 支持在微信小程序中加载和渲染 PAGX 文件 +- 基于 WebAssembly + WebGL 的高性能渲染引擎 +- 提供 `PAGXInit` 初始化接口,支持自定义 WASM 文件路径 +- 提供 `View` 类,支持以下能力: + - 加载 PAGX 文件数据(`loadPAGX`) + - 获取内容尺寸(`contentWidth` / `contentHeight`) + - 缩放与偏移控制(`updateZoomScaleAndOffset` / `onZoomEnd`) + - 渲染控制(`startRendering` / `stopRendering` / `isCurrentlyRendering`) + - 渲染回调(`setRenderCallbacks`) + - 视图尺寸更新(`updateSize`) + - 资源销毁(`destroy`) +- WASM 文件采用 Brotli 压缩(~470KB) diff --git a/pagx/wechat/CMakeLists.txt b/pagx/wechat/CMakeLists.txt new file mode 100644 index 0000000000..30c32cc299 --- /dev/null +++ b/pagx/wechat/CMakeLists.txt @@ -0,0 +1,137 @@ +cmake_minimum_required(VERSION 3.13) +project(PAGXViewer) + +# Include vendor tools from libpag root +set(_VENDOR_CMAKE ${CMAKE_CURRENT_SOURCE_DIR}/../../third_party/vendor_tools/vendor.cmake) +if (NOT EXISTS "${_VENDOR_CMAKE}") + message(FATAL_ERROR "vendor.cmake not found at ${_VENDOR_CMAKE}. Run sync_deps.sh first.") +endif () +include(${_VENDOR_CMAKE}) + +set(CMAKE_VERBOSE_MAKEFILE ON) +set(CMAKE_CXX_STANDARD 17) + +if (NOT CMAKE_BUILD_TYPE) + set(CMAKE_BUILD_TYPE "Release") +endif () + +# Set TGFX_DIR for libpag structure (tgfx is in third_party/tgfx) +if (NOT TGFX_DIR) + set(TGFX_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../third_party/tgfx) +endif () +get_filename_component(TGFX_DIR "${TGFX_DIR}" REALPATH) +if (NOT EXISTS "${TGFX_DIR}/CMakeLists.txt") + message(FATAL_ERROR "TGFX not found at ${TGFX_DIR}. Run sync_deps.sh first.") +endif () + +# Enable WASM SIMD optimization globally (must be set before add_subdirectory) +# This enables 128-bit SIMD instructions for faster image processing and matrix operations. +# Highway library in tgfx will automatically use SIMD code paths when compiled with -msimd128. +# Supported: iOS WeChat (iOS 14.5+), Android WeChat (most devices), Chrome 91+. +# NOTE: Currently disabled because enabling -msimd128 causes abnormal preview rendering +# on iOS WeChat mini programs. +# if (DEFINED EMSCRIPTEN) +# message(STATUS "WASM SIMD optimization enabled (-msimd128)") +# set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -msimd128") +# set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -msimd128") +# endif () + +# Add tgfx and pagx as dependencies +if (NOT TARGET tgfx) + set(CMAKE_POLICY_DEFAULT_CMP0077 NEW) + set(TGFX_BUILD_SVG ON CACHE BOOL "" FORCE) + set(TGFX_BUILD_LAYERS ON CACHE BOOL "" FORCE) + set(TGFX_USE_FREETYPE ON) + add_subdirectory(${TGFX_DIR} ${CMAKE_CURRENT_BINARY_DIR}/tgfx) +endif () + +# Build pagx static library +file(GLOB PAGX_CORE_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/../../src/pagx/*.cpp) +file(GLOB_RECURSE PAGX_ANIMATION_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/../../src/pagx/animation/*.cpp) +file(GLOB_RECURSE PAGX_NODES_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/../../src/pagx/nodes/*.cpp) +file(GLOB_RECURSE PAGX_RUNTIME_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/../../src/pagx/runtime/*.cpp) +file(GLOB_RECURSE PAGX_UTILS_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/../../src/pagx/utils/*.cpp) +# Woff2FontGenerator depends on the woff2 library which is only needed for HTML/SVG export. +list(FILTER PAGX_UTILS_SOURCES EXCLUDE REGEX "Woff2FontGenerator\\.cpp$") +file(GLOB_RECURSE PAGX_XML_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/../../src/pagx/xml/*.cpp) +# Only SVGPathParser is needed (used by PAGXImporter for path data). SVG/HTML exporters are not +# used in the wechat viewer and depend on woff2 which is not available in this build. +set(PAGX_SVG_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/../../src/pagx/svg/SVGPathParser.cpp) +if (NOT TARGET pagx) + add_library(pagx STATIC ${PAGX_CORE_SOURCES} ${PAGX_ANIMATION_SOURCES} ${PAGX_NODES_SOURCES} ${PAGX_RUNTIME_SOURCES} ${PAGX_UTILS_SOURCES} ${PAGX_XML_SOURCES} ${PAGX_SVG_SOURCES}) + target_include_directories(pagx PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/../../include) + target_include_directories(pagx PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/../../src + ${CMAKE_CURRENT_SOURCE_DIR}/../../third_party/expat/expat/lib) + target_compile_definitions(pagx PUBLIC PAG_USE_HARFBUZZ PAG_BUILD_PAGX) + if(WIN32) + target_compile_definitions(pagx PRIVATE XML_STATIC) + endif() + add_vendor_target(pagx-vendor STATIC_VENDORS expat SheenBidi harfbuzz CONFIG_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../..) + find_vendor_libraries(pagx-vendor STATIC PAGX_VENDOR_STATIC_LIBRARIES) + target_link_libraries(pagx PUBLIC tgfx) + add_dependencies(pagx pagx-vendor) + merge_libraries_into(pagx ${PAGX_VENDOR_STATIC_LIBRARIES}) +endif () + +# Build renderer sources as a static library +file(GLOB_RECURSE RENDERER_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/../../src/renderer/*.cpp) +if (NOT TARGET pagx-renderer) + add_library(pagx-renderer STATIC ${RENDERER_SOURCES}) + target_include_directories(pagx-renderer PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/../../src/renderer) + target_include_directories(pagx-renderer PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/../../src + ${CMAKE_CURRENT_SOURCE_DIR}/../../third_party/harfbuzz/src + ${CMAKE_CURRENT_SOURCE_DIR}/../../third_party/SheenBidi/Headers) + target_link_libraries(pagx-renderer PUBLIC pagx tgfx) +endif () + +# For Emscripten builds, pagx needs the same compile options as tgfx +if (DEFINED EMSCRIPTEN) + target_compile_options(pagx PRIVATE -fno-rtti -DEMSCRIPTEN_HAS_UNBOUND_TYPE_NAMES=0) + target_compile_options(pagx-renderer PRIVATE -fno-rtti -DEMSCRIPTEN_HAS_UNBOUND_TYPE_NAMES=0) +endif () + +file(GLOB_RECURSE PAGX_VIEWER_FILES src/*.cpp) + +if (DEFINED EMSCRIPTEN) + add_executable(pagx-viewer ${PAGX_VIEWER_FILES}) + list(APPEND PAGX_VIEWER_COMPILE_OPTIONS -fno-rtti -DEMSCRIPTEN_HAS_UNBOUND_TYPE_NAMES=0) + list(APPEND PAGX_VIEWER_LINK_OPTIONS --no-entry -lembind -fno-rtti -DEMSCRIPTEN_HAS_UNBOUND_TYPE_NAMES=0 -sEXPORT_NAME='PAGXWasm' -sWASM=1 + -sMAX_WEBGL_VERSION=2 -sEXPORTED_RUNTIME_METHODS=['GL','HEAPU8'] -sMODULARIZE=1 + -sENVIRONMENT=web,worker) + set(unsupported_emsdk_versions "4.0.11") + foreach (unsupported_version IN LISTS unsupported_emsdk_versions) + if (${EMSCRIPTEN_VERSION} VERSION_EQUAL ${unsupported_version}) + message(FATAL_ERROR "Emscripten version ${EMSCRIPTEN_VERSION} is not supported.") + endif () + endforeach () + if (EMSCRIPTEN_PTHREADS) + list(APPEND PAGX_VIEWER_LINK_OPTIONS -sUSE_PTHREADS=1 -sINITIAL_MEMORY=32MB -sALLOW_MEMORY_GROWTH=1 + -sPTHREAD_POOL_SIZE=navigator.hardwareConcurrency + -sEXIT_RUNTIME=0 -sINVOKE_RUN=0 -sMALLOC=mimalloc) + list(APPEND PAGX_VIEWER_COMPILE_OPTIONS -fPIC -pthread) + else () + list(APPEND PAGX_VIEWER_LINK_OPTIONS -sINITIAL_MEMORY=128MB -sALLOW_MEMORY_GROWTH=1) + endif () + if (CMAKE_BUILD_TYPE STREQUAL "Debug") + list(APPEND PAGX_VIEWER_COMPILE_OPTIONS -O0 -g3) + list(APPEND PAGX_VIEWER_LINK_OPTIONS -O0 -g3 -sSAFE_HEAP=1 -Wno-limited-postlink-optimizations) + else () + list(APPEND PAGX_VIEWER_COMPILE_OPTIONS -Oz) + list(APPEND PAGX_VIEWER_LINK_OPTIONS -Oz) + endif () +else () + add_library(pagx-viewer SHARED ${PAGX_VIEWER_FILES}) +endif () + +target_include_directories(pagx-viewer PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../../src) + +if (NO_LOG) + target_compile_definitions(pagx-viewer PRIVATE NO_LOG) +endif() + +target_compile_options(pagx-viewer PUBLIC ${PAGX_VIEWER_COMPILE_OPTIONS}) +target_link_options(pagx-viewer PUBLIC ${PAGX_VIEWER_LINK_OPTIONS}) +target_link_libraries(pagx-viewer pagx-renderer) diff --git a/pagx/wechat/GETTING_STARTED.md b/pagx/wechat/GETTING_STARTED.md new file mode 100644 index 0000000000..89bed28ba3 --- /dev/null +++ b/pagx/wechat/GETTING_STARTED.md @@ -0,0 +1,1040 @@ +# @tencent/pagx-viewer-miniprogram + +微信小程序 PAGX 查看器 SDK,提供在微信小程序中渲染 PAGX 动画文件的能力。基于 TGFX 和 WebAssembly 实现高性能 WebGL 渲染。 + +> 当前版本:**v1.9.6** + +## 目录结构 + +``` +pagx-viewer-miniprogram/ +├── package.json +├── lib/ +│ ├── pagx-viewer.js # PAGX 核心库(含 WASM 绑定) +│ ├── pagx-viewer.wasm # 未压缩 WASM 模块(~3.4MB) +│ ├── pagx-viewer.wasm.br # Brotli 压缩 WASM 模块(~980KB) +│ └── pagx-check.js # 渲染卡顿预检工具(可选,独立模块) +├── types/ # TypeScript 类型定义 +│ ├── pagx/wechat/ts/ # PAGX 核心类型(pagx / pagx-view / pagx-check / types ...) +│ └── third_party/tgfx/ # TGFX 图形库类型 +├── CHANGELOG.md +└── README.md +``` + +## 环境要求 + +- 微信开发者工具 +- 微信小程序基础库 ≥ **2.24.4** +- 支持 WebGL 的真机或模拟器 +- 微信小程序需使用 `WXWebAssembly`(非标准 `WebAssembly`) +- 必须使用**单线程**版本 WASM(微信不支持 `SharedArrayBuffer`) + +## 安装使用 + +### 1. 引入 SDK + +将 `lib/` 目录复制到小程序项目的 `utils/` 目录下,或通过 npm 引入: + +```json +{ + "dependencies": { + "@tencent/pagx-viewer-miniprogram": "1.9.5" + } +} +``` + +### 2. 配置合法域名 + +在 [微信小程序后台](https://mp.weixin.qq.com/) → 开发 → 开发管理 → 开发设置 → 服务器域名中,添加 `downloadFile` 合法域名:`https://pag.qq.com` + +> 如使用三步加载含外部图片的 PAGX 文件,还需额外添加以下合法域名: +> - Emoji 图片:`https://dev-static.d.gtimg.com` +> - Hash 图片:COS 存储桶对应的域名 + +## 快速开始 + +```javascript +import { PAGXInit } from './utils/pagx-viewer'; + +Page({ + View: null, + module: null, + canvas: null, + dpr: 2, + fontData: null, + emojiFontData: null, + + async onLoad() { + this.dpr = wx.getSystemInfoSync().pixelRatio || 2; + + // 字体下载与 Viewer 初始化并行执行 + await Promise.all([ + this.loadFonts(), + this.initializeViewer() + ]); + + await this.loadCurrentFile(); + + // 设置渲染回调并开始渲染 + this.View.setRenderCallbacks(null, () => { + // 帧渲染完成回调 + }); + this.View.startRendering(); + }, + + async initializeViewer() { + // 初始化 WASM 模块 + this.module = await PAGXInit({ + locateFile: (file) => '/utils/' + file + }); + + // 获取 Canvas 节点 + const query = wx.createSelectorQuery(); + const canvas = await new Promise((resolve) => { + query.select('#pagx-canvas') + .node() + .exec((res) => resolve(res[0].node)); + }); + this.canvas = canvas; + + // 设置 Canvas 物理像素尺寸 + const rect = await new Promise((resolve) => { + query.select('#pagx-canvas') + .boundingClientRect() + .exec((res) => resolve(res[0])); + }); + this.canvas.width = Math.floor(rect.width * this.dpr); + this.canvas.height = Math.floor(rect.height * this.dpr); + + // 创建 View 实例 + this.View = await this.module.View.init(this.module, this.canvas, { + autoRender: false + }); + }, + + // 从项目 CDN 下载字体文件 + async loadFonts() { + const [fontData, emojiFontData] = await Promise.all([ + this.downloadFile('https://pag.qq.com/wx_pagx_demo/fonts/NotoSansSC-Regular.otf'), + this.downloadFile('https://pag.qq.com/wx_pagx_demo/fonts/NotoColorEmoji.ttf') + ]); + this.fontData = fontData; + this.emojiFontData = emojiFontData; + }, + + async loadCurrentFile() { + const url = 'https://pag.qq.com/wx_pagx_demo/ColorPicker.pagx'; + const data = await this.downloadFile(url); + + // 加载 PAGX 前注册字体,只需要注册一次 + const font = this.fontData || new Uint8Array(0); + const emoji = this.emojiFontData || new Uint8Array(0); + this.View.registerFonts(font, emoji); + + this.View.loadPAGX(data); + }, + + downloadFile(url) { + return new Promise((resolve, reject) => { + wx.downloadFile({ + url, + success: (res) => { + const fs = wx.getFileSystemManager(); + const data = fs.readFileSync(res.tempFilePath); + resolve(new Uint8Array(data)); + }, + fail: reject + }); + }); + }, + + onUnload() { + if (this.View) { + this.View.destroy(); + this.View = null; + } + } +}); +``` + +### 页面模板 (WXML) + +```xml + + + + +``` + +### 加载流程 + +``` +[loadFonts() + PAGXInit()] 并行 → View.init() → View.registerFonts() → View.loadPAGX() → View.setRenderCallbacks() → View.startRendering() + +含外部图片时: +[loadFonts() + PAGXInit()] 并行 → View.init() → View.registerFonts() → View.parsePAGX() → getExternalFilePaths() → loadFileData() × N → buildLayers() → View.setRenderCallbacks() → View.startRendering() +``` + +## 核心 API + +### PAGXInit(options?) + +初始化 PAGX 模块,加载 WASM 文件。 + +```javascript +const module = await PAGXInit({ + locateFile: (file) => '/utils/' + file // 指定 WASM 文件路径 +}); +``` + +### View + +| 方法 | 说明 | +|------|------| +| `View.init(module, canvas, options?)` | 创建 View 实例 | +| `View.registerFonts(fontData, emojiFontData)` | 注册字体,需在 `loadPAGX()` 前调用 | +| `View.loadPAGX(data: Uint8Array)` | 一步加载 PAGX 文件(解析+构建层树) | +| `View.parsePAGX(data: Uint8Array)` | 仅解析 PAGX 文件,不构建层树(三步加载第 1 步) | +| `View.getExternalFilePaths()` | 获取外部图片资源路径列表(三步加载第 2 步) | +| `View.loadFileData(filePath, fileData)` | 加载外部文件字节流到对应 Image 节点(wasm 内解码) | +| `View.attachNativeImage(filePath, nativeImage, quality)` | 按质量等级(`Thumbnail` / `Full`)上传宿主解码图片为 GPU 常驻 backend texture,并接入 SDK 的 LRU 驱逐(v1.9+) | +| `View.setImageOriginalSize(filePath, width, height)` | 声明外部图片的原始像素尺寸,用于校正降采样注入后的 ImagePattern 矩阵(v1.9.3+) | +| `View.setTextureEventHandler(handler)` | 注册纹理生命周期回调(`onTextureRequest` / `onTextureEvict`)(v1.9+) | +| `View.isFullBudgetSaturated()` | 查询 Full 桶是否已越过硬上限(渐进升级循环 gating 用)(v1.9+) | +| `View.getImageBounds(filePaths)` | 查询图片在 root-space 下的 `unionBounds` / `largestBounds`,需在 `buildLayers()` 之后(v1.9+) | +| `View.getImageMetadata()` | 列出每个外部图片的原始尺寸 + 每处 `ImagePattern` 用法,需在 `parsePAGX()` 之后(v1.9+) | +| `View.buildLayers()` | 构建层树,完成渲染内容准备(三步加载第 3 步) | +| `View.contentWidth()` | 获取 PAGX 内容宽度(物理像素) | +| `View.contentHeight()` | 获取 PAGX 内容高度(物理像素) | +| `View.updateZoomScaleAndOffset(zoom, offsetX, offsetY)` | 直接设置缩放与偏移(绝对值,不做 clamp) | +| `View.panBy(deltaX, deltaY)` | 增量 pan + 自动 clamp + 越界报告(推荐手势驱动) | +| `View.beginPinchGesture()` | 开始 pinch 手势会话,抓 snapshot(v1.8+) | +| `View.pinchBy(scale, focalX, focalY)` | 绝对式 pinch zoom + 自动 clamp(v1.8+,覆盖手势/单次离散两种场景) | +| `View.endPinchGesture()` | 结束 pinch 手势会话,清 snapshot(v1.8+) | +| `View.setPadding(logicalPx)` | 配置单 Frame 预览单边留白(逻辑像素,默认 0) | +| `View.getViewportInfo()` | 一次性获取 viewport + content size + boundary 状态 | +| `View.onZoomEnd()` | ~~缩放结束通知~~(已弃用,内部自动检测) | +| `View.getContentTransform()` | 获取坐标转换参数,用于评论浮层定位 | +| `View.getNodePosition(nodeId)` | 通过节点 ID 查询节点相对于画布的位置 | +| `View.setBoundsOrigin(x, y)` | 手动设置 boundsOrigin,覆盖 PAGX 文件中的值 | +| `View.setGestureActive(active)` | pan/zoom 开始/结束时切换手势冻结快路径,native 侧 readback surface 作为 cachedSnapshot(v1.9+) | +| `View.setSnapshotEnabled(enabled)` | fitSnapshot 快路径开关,默认 `true`;关闭可让渐进式加载实时反映新图(v1.9+) | +| `View.resetForFreshCapture()` | 丢弃 init 之前的 cached / fit 快照、复位首帧标志(v1.9+) | +| `View.setRenderCallbacks(onBefore?, onAfter?)` | 设置帧渲染回调 | +| `View.startRendering()` | 开始持续渲染 | +| `View.stopRendering()` | 停止渲染 | +| `View.isFirstFrameRendered()` | 首帧是否已渲染 | +| `View.isCurrentlyRendering()` | 是否正在渲染 | +| `View.updateSize(width?, height?)` | 更新视图尺寸 | +| `View.destroy()` | 销毁实例并释放资源 | +| `View.decodeImageFromPath(filePath)` *(static)* | 把临时文件 / URL 解码到 `OffscreenCanvas`(小程序原生解码线程,可并发)(v1.9+) | +| `View.decodeImageFromBytes(bytes, hint?)` *(static)* | 把字节流落临时文件后调 `decodeImageFromPath`,自动清理(v1.9+) | + +#### View.init 的 options 参数 + +```typescript +interface PAGXViewOptions { + autoRender?: boolean; // 是否自动渲染,默认 false + onBeforeRender?: () => void; + onAfterRender?: () => void; + onFirstFrame?: () => void; // 首帧渲染回调 +} +``` + +#### View.registerFonts 参数 + +| 参数 | 类型 | 说明 | +|------|------|------| +| `fontData` | `Uint8Array` | 主字体文件数据(如 NotoSansSC-Regular.otf) | +| `emojiFontData` | `Uint8Array` | Emoji 字体文件数据(如 NotoColorEmoji.ttf) | + +> **注意**:`registerFonts()` 只需在首次 `loadPAGX()` 前调用一次,后续切换文件无需重复注册。 + +> **字体文件来源**:请自行下载字体文件后上传至项目自有 CDN,通过 `wx.downloadFile` 下载。SDK 不内置字体文件。 + +## 加载本地 PAGX 文件 + +除了从 CDN 下载,也可以加载小程序本地的 PAGX 文件: + +```javascript +async loadLocalFile() { + const fs = wx.getFileSystemManager(); + const data = fs.readFileSync('/assets/your-animation.pagx'); + this.View.loadPAGX(new Uint8Array(data)); +} +``` + +## 切换多个 PAGX 文件 + +```javascript +const SAMPLE_FILES = [ + { name: 'File1', url: 'https://pag.qq.com/wx_pagx_demo/file1.pagx' }, + { name: 'File2', url: 'https://pag.qq.com/wx_pagx_demo/file2.pagx' } +]; + +async switchFile(index) { + const data = await this.downloadFile(SAMPLE_FILES[index].url); + this.View.loadPAGX(data); +} +``` + +## 加载含外部图片的 PAGX 文件(三步加载) + +对于包含外部图片资源引用的 PAGX 文件,需要使用三步加载流程: + +### getExternalFilePaths 路径类型 + +`View.getExternalFilePaths()` 返回的路径有两种类型: + +| 前缀 | 示例 | 说明 | 下载方式 | +|------|------|------|---------| +| `emoji:` | `emoji:1F44B.png` | Emoji 图片资源 | 去掉 `emoji:` 前缀,拼接公开 CDN 地址直接下载 | +| `hash:` | `hash:51b01fc353d219abcc5ef2054abb832f6ec87001` | COS 存储的图片资源 | 去掉 `hash:` 前缀,通过 COS SDK 下载 | + +- **Emoji 类型**:拼接 CDN 地址 `https://dev-static.d.gtimg.com/emoji/apple/medium/` + 文件名即可直接下载,为公开资源 +- **Hash 类型**:需要集成 [COS 小程序 SDK](https://cloud.tencent.com/document/product/436/31953) 进行下载 + +> **参考文档**: +> - [COS 小程序 SDK 接入文档](https://cloud.tencent.com/document/product/436/31953) +> - [Cocraft COS Client 实现参考](https://git.woa.com/tencent-design/cocraft/blob/master/packages/common/frontend-common/src/services/cos-client.ts) + +### 示例代码 + +```javascript +const EMOJI_CDN = 'https://dev-static.d.gtimg.com/emoji/apple/medium/'; + +async loadPAGXWithExternalImages(url) { + const data = await this.downloadFile(url); + + // 第 1 步:解析 PAGX 文件(不构建层树) + this.View.parsePAGX(data); + + // 第 2 步:获取外部文件路径并逐一下载加载 + const paths = this.View.getExternalFilePaths(); + for (const filePath of paths) { + let fileData; + if (filePath.startsWith('emoji:')) { + // Emoji 图片:拼接公开 CDN 地址直接下载 + const fileName = filePath.slice('emoji:'.length); + fileData = await this.downloadFile(EMOJI_CDN + fileName); + } else if (filePath.startsWith('hash:')) { + // Hash 图片:通过 COS SDK 下载 + const cosKey = filePath.slice('hash:'.length); + fileData = await this.downloadFromCOS(cosKey); + } else { + console.warn('Unknown file path type:', filePath); + continue; + } + const success = this.View.loadFileData(filePath, fileData); + if (!success) { + console.warn('Failed to load external file:', filePath); + } + } + + // 第 3 步:构建层树,完成渲染准备 + this.View.buildLayers(); +} +``` + +> **说明**:`loadPAGX()` 等价于 `parsePAGX()` + `buildLayers()`。如果 PAGX 文件不包含外部图片引用,使用 `loadPAGX()` 一步加载即可。 + +### 加载流程对比 + +``` +简单模式: View.loadPAGX(data) ← 一步完成(无外部资源) +三步模式: View.parsePAGX(data) ← 解析文件 + → View.getExternalFilePaths() ← 获取外部资源列表 + → View.loadFileData(path, data) × N ← 逐一加载资源 + → View.buildLayers() ← 构建层树 +``` + +## 评论浮层坐标转换 + +PAGX Viewer 提供 `getContentTransform()` API,支持将 Cocraft 画布坐标系中的评论坐标转换为小程序屏幕像素位置,实现评论标记与 PAGX 内容的精确叠加显示。 + +### 坐标系概览 + +整个转换涉及三个坐标系: + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Cocraft 画布坐标系 │ +│ (无限画布,坐标可为负数) │ +│ │ +│ boundsOrigin │ +│ (-900, -193) │ +│ ┌──────────────┐ │ +│ │ PAGX 设计稿 │ ● 评论A (-381, 69) │ +│ │ 内容 │ 在设计稿右上方外侧 │ +│ │ (450 × 920) │ │ +│ │ │ │ +│ │ ● 评论B (-719, 301) │ +│ │ 在设计稿内部 │ +│ │ │ │ +│ └──────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ + + ↓ 坐标转换(位置关系保持不变) + +┌───────────────────────────────────────┐ +│ 小程序 Canvas 画布 │ +│ ┌─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ │ +│ │ centerOffset │ │ +│ │ ┌──────────────┐ │ │ +│ │ │ PAGX 内容 │ ● 评论A │ │ +│ │ │ (缩放+居中) │ 右上方外侧 │ │ +│ │ │ │ │ │ +│ │ │ ● 评论B │ │ +│ │ │ 内部 │ │ │ +│ │ │ │ │ │ +│ │ └──────────────┘ │ │ +│ └─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ │ +└───────────────────────────────────────┘ +``` + +**关键概念:** + +| 概念 | 说明 | +|------|------| +| **Cocraft 画布** | 无限大的设计画布,坐标可以为任意正负数 | +| **boundsOrigin** | PAGX 设计稿内容左上角在 Cocraft 画布中的坐标 | +| **fitScale** | PAGX 内容等比缩放到 Canvas 的缩放因子(contain 模式) | +| **centerOffset** | 缩放后居中显示产生的偏移量 | + +### 转换公式 + +#### 阶段一:初始定位(加载文件后计算一次) + +```javascript +const t = View.getContentTransform(); +// t = { boundsOriginX, boundsOriginY, fitScale, centerOffsetX, centerOffsetY } +// 新版本中 boundsOriginX, boundsOriginY 保存在 pagx 文件中 +// 旧版本中 boundsOriginX, boundsOriginY 由小程序端自行获取,可忽略 getContentTransform返回的 boundsOriginX, boundsOriginY +// 对每个评论计算 Canvas 物理像素坐标(静态值,缓存后续复用) +const baseX = (cocraftX - t.boundsOriginX) * t.fitScale + t.centerOffsetX; +const baseY = (cocraftY - t.boundsOriginY) * t.fitScale + t.centerOffsetY; +``` + +#### 阶段二:动态定位(缩放/平移时每帧更新) + +```javascript +// zoom/panOffsetX/panOffsetY 来自用户手势回调 +const screenX = (baseX * zoom + panOffsetX) / dpr; +const screenY = (baseY * zoom + panOffsetY) / dpr; +``` + +| 参数 | 初始值 | 说明 | +|------|--------|------| +| zoom | 1 | 用户捏合缩放倍率 | +| panOffsetX | 0 | 水平平移量(物理像素) | +| panOffsetY | 0 | 垂直平移量(物理像素) | +| dpr | 设备像素比 | 物理像素 → CSS 像素的换算因子 | + +> **为什么除以 dpr?** Canvas 使用物理像素坐标(确保高清渲染),而 WXML 元素使用 CSS 像素定位。 + +### 完整流程 + +``` + const t = View.getContentTransform() ← 加载 PAGX 后调用一次 + ┌───────────────────────────────────────────────────┐ + │ t.boundsOriginX/Y ← PAGX 内容在 Cocraft 的位置 │ + │ t.fitScale ← 等比缩放因子 │ + │ t.centerOffsetX/Y ← 居中偏移 │ + └──────────────────────────┬────────────────────────┘ + │ + Cocraft 坐标 ▼ + (cocraftX, cocraftY) + │ + ▼ + Canvas 物理像素 baseX = (cocraftX - t.boundsOriginX) × t.fitScale + t.centerOffsetX + (baseX, baseY) baseY = (cocraftY - t.boundsOriginY) × t.fitScale + t.centerOffsetY + │ ↑ 静态,缓存 + │ + ▼ screenX = (baseX × zoom + panOffsetX) / dpr + 屏幕 CSS 像素 screenY = (baseY × zoom + panOffsetY) / dpr + (screenX, screenY) ↑ 动态,每帧 + │ + ▼ + 设置为评论元素的 left / top +``` + +### 接入示例 + +#### 1. 准备 Page data + +```javascript +Page({ + data: { + commentPins: [], // [{id, baseX, baseY, text, color}] + commentTransform: null // {zoom, panOffsetX, panOffsetY, dpr} + }, + dpr: 2, +}) +``` + +#### 2. 加载 PAGX 后计算评论基准坐标 + +```javascript +initCommentOverlays(comments) { + if (!this.View) return; + + const t = this.View.getContentTransform(); + const pins = comments.map(function(c) { + return { + id: c.id, + text: c.text, + color: c.color, + baseX: (c.cocraftX - t.boundsOriginX) * t.fitScale + t.centerOffsetX, + baseY: (c.cocraftY - t.boundsOriginY) * t.fitScale + t.centerOffsetY, + }; + }); + + this.setData({ + commentPins: pins, + commentTransform: { + zoom: 1, panOffsetX: 0, panOffsetY: 0, dpr: this.dpr + } + }); +} +``` + +#### 3. WXS 渲染线程动态定位 + +创建 `comment.wxs` 在渲染线程上直接更新评论位置,避免 `setData` 延迟: + +```javascript +// comment.wxs +function onTransformChanged(newVal, oldVal, ownerInstance, instance) { + if (!newVal || !newVal.zoom) return; + var dataset = instance.getDataset(); + var screenX = (dataset.basex * newVal.zoom + newVal.panOffsetX) / newVal.dpr; + var screenY = (dataset.basey * newVal.zoom + newVal.panOffsetY) / newVal.dpr; + instance.setStyle({ left: screenX + 'px', top: screenY + 'px' }); +} + +module.exports = { onTransformChanged: onTransformChanged }; +``` + +#### 4. WXML 模板 + +```xml + + + + + + + + {{item.text}} + + +``` + +> **注意:** Canvas 和评论浮层需要在同一个 `position: relative` 的容器内,评论使用 `position: absolute` 定位。 + +#### 5. 手势回调中同步更新评论变换 + +```javascript +applyGestureState(state) { + if (!state || !this.View) return; + + this.View.updateZoomScaleAndOffset(state.zoom, state.offsetX, state.offsetY); + + this.setData({ + commentTransform: { + zoom: state.zoom, + panOffsetX: state.offsetX, + panOffsetY: state.offsetY, + dpr: this.dpr + } + }); +} +``` + +### View.getContentTransform() 返回值 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `boundsOriginX` | number | PAGX 内容左上角在 Cocraft 画布中的 X 坐标 | +| `boundsOriginY` | number | PAGX 内容左上角在 Cocraft 画布中的 Y 坐标 | +| `fitScale` | number | 等比缩放因子(contain 模式) | +| `centerOffsetX` | number | 水平居中偏移(物理像素) | +| `centerOffsetY` | number | 垂直居中偏移(物理像素) | + +### View.getNodePosition(nodeId) + +通过节点 ID 查询节点相对于画布的位置坐标。用于获取特定节点在渲染画布中的位置,可用于交互定位、节点高亮、锚点跳转等场景。 + +```javascript +const result = View.getNodePosition('node-12345'); +if (result.found) { + console.log('Node position:', result.x, result.y); +} else { + console.log('Node not found or has no position data'); +} +``` + +#### 返回值 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `found` | boolean | 是否找到节点且包含有效的位置数据 | +| `x` | number | 节点相对于画布的 X 坐标(未找到时为 0) | +| `y` | number | 节点相对于画布的 Y 坐标(未找到时为 0) | + +### View.setBoundsOrigin(x, y) + +手动设置 boundsOrigin,覆盖 PAGX 文件中的值。当 PAGX 文件未嵌入 `bounds-origin` 数据时使用。 + +## 单 Frame 预览与手势接入(v1.7+) + +`v1.7+` 提供了完整的"单 Frame 预览 + 手势"工具集:`setPadding` / `panBy` / +`beginPinchGesture` / `pinchBy` / `endPinchGesture` / `getViewportInfo`。SDK +内部统一处理 fitScale、padding、pan/zoom 边界 clamp,业务层只需把触摸事件 +翻译成对应调用。 + +> **注意(v1.8 破坏性变更)**:v1.7 的 `zoomBy(scaleDelta, focalX, focalY)` +> 已删除——它的增量式语义在连续 pinch 手势中会累积浮点误差 + focal 偏移挤压, +> 视觉上表现为缩放过程中持续平移。请改用 `pinchBy` 系列接口。 + +### 初始化 + +```javascript +// 加载完 PAGX 后配置单边留白(逻辑像素,默认 0) +View.setPadding(100); +// 复位到初始 fit + 居中 +View.updateZoomScaleAndOffset(1, 0, 0); +``` + +### 单指 pan(带越界报告) + +```javascript +const dpr = wx.getSystemInfoSync().pixelRatio; +let lastX, lastY; + +onTouchStart(e) { + if (e.touches.length === 1) { + lastX = e.touches[0].x; + lastY = e.touches[0].y; + } +}, +onTouchMove(e) { + if (e.touches.length !== 1) return; + const deltaX = (e.touches[0].x - lastX) * dpr; // 物理像素增量 + const deltaY = (e.touches[0].y - lastY) * dpr; + lastX = e.touches[0].x; + lastY = e.touches[0].y; + + const r = View.panBy(deltaX, deltaY); + // r.remainingX > 0:用户继续往右拖但 SDK 已 clamp(已贴右边界) + // r.remainingX < 0:用户继续往左拖但 SDK 已 clamp(已贴左边界) + // 业务层据此决定是否触发翻页 / 弹性反馈 + if (r.remainingX !== 0) { + // ...更新越界 UI(如箭头提示) + } +} +``` + +### 双指 pinch zoom(手势会话) + +```javascript +let initialDist = 0; +let focalX = 0; +let focalY = 0; + +onTouchStart(e) { + if (e.touches.length === 2) { + initialDist = Math.hypot( + e.touches[1].x - e.touches[0].x, + e.touches[1].y - e.touches[0].y + ); + // 锁定双指开始时的捏合中心作为 zoom 锚点(物理像素)。整个手势期间不变。 + focalX = (e.touches[0].x + e.touches[1].x) * 0.5 * dpr; + focalY = (e.touches[0].y + e.touches[1].y) * 0.5 * dpr; + View.beginPinchGesture(); // SDK 抓 snapshot + } +}, +onTouchMove(e) { + if (e.touches.length !== 2) return; + const currentDist = Math.hypot( + e.touches[1].x - e.touches[0].x, + e.touches[1].y - e.touches[0].y + ); + // scale 是从手势起点到当前的累计比例(绝对值),不是每帧 delta + const scale = currentDist / initialDist; + View.pinchBy(scale, focalX, focalY); + // SDK 内部基于 begin 时的 snapshot 重算 zoom/offset,每帧都是绝对计算 → + // 无浮点累积、focal 那个像素视觉上不动、其他内容向 focal 辐射缩放。 +}, +onTouchEnd(e) { + if (e.touches.length < 2) { + View.endPinchGesture(); // 清 snapshot + } +} +``` + +### 单次 zoom(鼠标滚轮 / 双击 / 按钮) + +无需 begin/end,直接调 `pinchBy` 单次: + +```javascript +// 鼠标滚轮放大 10% +View.pinchBy(1.1, mouseX, mouseY); + +// 双击放大 2 倍 +View.pinchBy(2, tapX, tapY); + +// 按钮 zoom-in 20%(围绕画布中心) +View.pinchBy(1.2, canvasWidth / 2, canvasHeight / 2); +``` + +未在手势会话内调用时,`pinchBy` 基于当前 view 状态做一次绝对 zoom,等价于 +v1.7 旧版 `zoomBy(scale, x, y)` 的单次调用语义。 + +### 查询当前视口 + +```javascript +const v = View.getViewportInfo(); +// { +// zoom, offsetX, offsetY, // 当前 transform +// canvasWidth, canvasHeight, // canvas 物理像素 +// contentWidth, contentHeight, // 文档原始尺寸 +// fitScale, // SDK 内部 contain fit 系数 +// atLeft, atRight, atTop, atBottom, // 边界标记(0.5px 容差) +// } + +if (v.atLeft) { + // 文档已贴到左边界 +} +``` + +### 复位 + +```javascript +View.updateZoomScaleAndOffset(1, 0, 0); +``` + +### 何时直接用 updateZoomScaleAndOffset? + +- **绝对跳转**:已知目标 zoom/offset 直接写入(例如恢复历史状态) +- **自带 clamp 的业务**:业务层已经维护一套自己的边界策略,不希望 SDK 介入 + +否则推荐用 `panBy / pinchBy` —— SDK 会自动应用 clamp 策略,业务层无需关心 +fitScale、padding、minZoom 等显示数学。 + +## 常见问题 + +### Canvas 显示黑屏或空白 + +- 检查 WASM 文件路径是否正确:`locateFile: (file) => '/utils/' + file` +- 检查 PAGX 文件是否加载成功 +- 检查 Canvas 尺寸是否正确设置了 DPR + +### 报错:WebAssembly is not defined + +微信小程序使用 `WXWebAssembly` 而非标准 `WebAssembly`,需确保使用适配微信环境的构建产物。 + +### WASM 加载失败 + +- 检查 WASM 文件是否存在 +- 确保使用单线程版本(微信不支持 `SharedArrayBuffer`) + +### 下载文件失败 + +- 检查 CDN URL 是否可访问 +- 确认已在小程序后台配置 `downloadFile` 合法域名 +- 确保 CDN 支持 HTTPS + +### 手势操作不生效 + +- `View.updateZoomScaleAndOffset()` 需要传入正确的缩放比例和偏移量(物理像素) +- 确保 DPR 参数正确:`this.dpr = wx.getSystemInfoSync().pixelRatio || 2` + +### 渲染模糊 + +需设置 Canvas 物理像素尺寸: + +```javascript +this.canvas.width = Math.floor(rect.width * this.dpr); +this.canvas.height = Math.floor(rect.height * this.dpr); +``` + +### 内存占用过高 + +页面卸载时及时清理资源: + +```javascript +onUnload() { + if (this.View) { this.View.destroy(); this.View = null; } +} +``` + +### 为什么用 WXS 而不是 setData 更新评论位置 + +`setData` 是异步的(逻辑层 → 渲染层通信),在快速缩放/平移时会导致评论标记跟不上 PAGX 画面。WXS 直接运行在渲染线程上,可以同步更新 DOM 样式,保证评论标记与画面同步移动。 + +### 评论显示在画布外面 + +这是正常的。评论在 Cocraft 中可能就在设计稿外面(比如标注在旁边),转换后自然也在 PAGX 渲染区域外面。用户缩小画布后就能看到。如果不希望显示画布外的评论,可以对 `baseX`/`baseY` 做范围判断后过滤。 + +### boundsOriginX/Y 返回 0 + +说明 PAGX 文件中没有嵌入 `bounds-origin` 数据。需要: +- 确认 Cocraft 导出时已将 `bounds-origin-x` / `bounds-origin-y` 写入 PAGX 文件根节点的 `data-*` 属性 +- 或者在 JS 端手动调用 `View.setBoundsOrigin(x, y)` 设置 + +> **版本兼容说明**:现有版本中,小程序端可自行获取 bounds-origin 参数;后续新版本中 PAGX 文件自身会携带 bounds-origin 相关参数。 + +### 切换 PAGX 文件后评论位置不对 + +每次调用 `View.loadPAGX()` 后,`getContentTransform()` 返回的参数可能不同(不同文件的 boundsOrigin、内容尺寸不同),必须重新计算所有评论的 `baseX`/`baseY`。 + +### Canvas 尺寸变化后评论位置不对 + +Canvas 尺寸变化会影响 `fitScale` 和 `centerOffset`。调用 `View.updateSize()` 后需要重新调用 `getContentTransform()` 并重新计算所有评论的 `baseX`/`baseY`。 + +## 渐进式图片加载(v1.9+) + +`v1.9` 引入一套渐进式图片加载工具链,把 webp/png/jpeg 解码从 wasm 主线程下放到小程序原生 +解码器,并在 SDK 内部维护**缩略图 + 高清图**双桶 LRU 缓存。业务层只需关心"下载哪张图", +其余(GPU 上传、内存预算、淘汰、缩略图兜底)由 SDK 接管。 + +### 加载流程 + +``` +parsePAGX() ← 解析 PAGX 文件 + → getImageMetadata() ← 拿到所有图片的原始尺寸 + ImagePattern 用法 + → buildLayers() ← 立刻完成布局(图片节点暂为空) + +setTextureEventHandler({...}) ← 注册纹理生命周期回调(解决驱逐后的回填) + +// 第一阶段:填充缩略图,让所有画面非空 +for (const path of view.getExternalFilePaths()) { + attachNativeImage(path, decodedThumbCanvas, ImageQuality.Thumbnail); +} + +// 第二阶段:渐进升级到高清(首屏 / 视口附近优先) +const bounds = view.getImageBounds(viewportPaths); +// 按 bounds 与视口距离排序,逐一上传 Full: +for (const path of sortedPaths) { + if (view.isFullBudgetSaturated()) break; + attachNativeImage(path, decodedFullCanvas, ImageQuality.Full); +} +``` + +### 双桶模型 + +| 桶 | 来源 | 是否参与 LRU | 兜底语义 | +|----|------|-------------|---------| +| `Thumbnail` | `attachNativeImage(path, canvas, ImageQuality.Thumbnail)` | 不参与(只在桶满时静默淘汰最旧条目)| `Full` 缺失或被驱逐时自动回退绘制 | +| `Full` | `attachNativeImage(path, canvas, ImageQuality.Full)` | 每帧 LRU 扫描,超预算驱逐时触发 `onTextureEvict` | 主显示纹理 | + +被 LRU 扫到的 Full 路径会通过 `onTextureRequest(filePath)` 在下一帧渲染时再次请求;业务层 +拉到原图后再次 `attachNativeImage(..., Full)` 即可。**对 `onTextureRequest` 的响应始终是 +1:1 替换**,不会让总字节数超出预算。 + +### 解码工具 + +```javascript +// 1. 已有 URL / 临时文件路径 +const canvas = await View.decodeImageFromPath(tempFilePath); + +// 2. 已有字节流(自动落临时文件 → 解码 → 删除) +const canvas = await View.decodeImageFromBytes(bytes, 'thumb.webp'); + +// 3. 注入到对应 Image 节点 +view.attachNativeImage(filePath, canvas, ImageQuality.Full); +// canvas 可在调用返回后立即丢弃,SDK 已把像素拷到 GPU +``` + +`decodeImageFromPath` 走小程序原生 webp/png/jpeg 解码线程,多张图可并发;多次解码不会 +阻塞 wasm 渲染主线程。 + +### 完整示例 + +```javascript +import { PAGXInit, ImageQuality } from './utils/pagx-viewer'; + +async function progressiveLoad(view, pagxBytes) { + view.parsePAGX(pagxBytes); + + // 注册回调:被 LRU 驱逐的路径要在下一帧重新填充 + view.setTextureEventHandler({ + onTextureRequest(filePath) { + // 异步取原图 → 解码 → 上传 Full(替换性 1:1,安全) + fetchAndAttach(filePath, ImageQuality.Full); + }, + onTextureEvict(paths) { + paths.forEach((p) => myLocalCache.drop(p)); + }, + }); + + view.buildLayers(); + + // 第一阶段:所有图都先挂缩略图,画面立刻非空 + const metadata = view.getImageMetadata(); + await Promise.all(metadata.map(async (m) => { + const thumbBytes = await downloadThumb(m.filePath, pickThumbSize(m)); + const canvas = await View.decodeImageFromBytes(thumbBytes); + view.attachNativeImage(m.filePath, canvas, ImageQuality.Thumbnail); + })); + + // 第二阶段:按视口距离 / 显示面积排序,渐进升级 Full + const bounds = view.getImageBounds(metadata.map((m) => m.filePath)); + const sortedPaths = sortByViewportDistance(bounds); + for (const filePath of sortedPaths) { + if (view.isFullBudgetSaturated()) break; + await fetchAndAttach(filePath, ImageQuality.Full); + } + + async function fetchAndAttach(filePath, quality) { + const bytes = await myDownloader(filePath); + const canvas = await View.decodeImageFromBytes(bytes); + view.attachNativeImage(filePath, canvas, quality); + } +} +``` + +### ImagePattern 矩阵校正 + +New-format PAGX 将 ImagePattern 变换矩阵烘焙在原始图片的像素坐标系中。当宿主通过 +`attachNativeImage()` 注入降采样变体(如 256×256 缩略图替代 1024×1024 原图)时,SDK +需要用 `diag(origW/actualW, origH/actualH)` 后乘原矩阵来保持填充对齐。 + +**调用时机**:`parsePAGX()` 之后、`buildLayers()` / `attachNativeImage()` 之前。 + +```javascript +const metadata = view.getImageMetadata(); + +// 对每个外部图片注册原始尺寸 +metadata.forEach((m) => { + if (m.origWidth > 0 && m.origHeight > 0) { + view.setImageOriginalSize(m.filePath, m.origWidth, m.origHeight); + } +}); + +// 之后再用 attachNativeImage 注入缩略图或高清图 +view.attachNativeImage(m.filePath, thumbCanvas, ImageQuality.Thumbnail); +``` + +### 注意事项 + +- `getImageBounds()` 必须在 `buildLayers()` 之后调用;首次调用因 tgfx 内部 lazy 计算 + localBounds 较重,建议放到首帧渲染完成后的 idle 时机(如 `setTimeout(0)` 或 `wx.nextTick`) +- `getImageMetadata()` 需在 `parsePAGX()` 之后调用,可作为决定缩略图档位的依据 +- 渐进式加载场景下,建议关闭 `setSnapshotEnabled(false)`,避免缩略图被永久 cache 而看不到高清升级 +- 推荐统一使用 `attachNativeImage(..., quality)`,享受 LRU + 缩略图兜底 + +## PAGX 渲染卡顿预检(v1.9+) + +针对低端机加载复杂 PAGX 文件可能卡顿的场景,SDK 提供 `CheckPagx(pagxData)` 异步预检接口, +业务层可在加载前快速判断当前设备 + 当前文件是否可顺畅渲染。 + +> **独立模块**:`CheckPagx` 是一个**可选的**独立 JS 模块(`lib/pagx-check.js`),不包含在 +> `pagx-viewer.js` 中,也不依赖 WASM 初始化或 WebGL 上下文。不需要卡顿预检的宿主可以完全 +> 不引入此文件,不影响渲染功能。 + +```javascript +const { CheckPagx } = require('./utils/pagx-check'); + +Page({ + async onLoad() { + await this.safeLoad('/assets/your-animation.pagx'); + }, + + async safeLoad(filePath) { + const fs = wx.getFileSystemManager(); + const data = new Uint8Array(fs.readFileSync(filePath)); + + const result = await CheckPagx(data); + // result = { score, benchmarkLevel, deviceTier, platform } + + const minScore = result.platform === 'android' ? 65 : 75; + if (result.score < minScore) { + wx.showToast({ title: '该文件可能导致卡顿', icon: 'none' }); + return; + } + + // 通过预检 → 正常加载 + this.View.loadPAGX(data); + }, +}); +``` + +### 评分模型 + +SDK 沿"五条独立失效路径"中最高风险路径打分(0-100,越高越流畅): + +| 路径 | 计算 | +|------|------| +| A. BgBlur × 下方不可缓存元素 | `bg_count × (inner + blur + grad/10)` | +| B. Path 几何量 | `path_data_bytes (MB)` | +| C. 大画布 × 元素密度 | `(pix_M/100) × (imgPat + layer/30 + grad/20)` | +| D. BgBlur 数量 | `bg_count` | +| E. Layer XML 数量 | 源 XML 中 `` 数量 | + +### 设备档位(按 `wx.getDeviceBenchmarkInfo`) + +| 平台 | 高端机 | 中端机 | 低端机 | +|------|-------|-------|-------| +| Android | `benchmarkLevel ≥ 30` | `23–29` | `≤ 22` | +| iOS | `benchmarkLevel ≥ 36` | `30–35` | `≤ 29` | + +中低端机阈值会自动收紧。`benchmarkLevel = -1` 时 `deviceTier = 'unknown'`,使用最保守阈值。 + +### 返回值 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `score` | `number` | 0-100,越高越流畅 | +| `benchmarkLevel` | `number` | 微信 API 原始性能等级(-1 = 未知)| +| `deviceTier` | `'high' \| 'mid' \| 'low' \| 'unknown'` | 档位 | +| `platform` | `'ios' \| 'android' \| 'other'` | 平台 | + +## 技术限制 + +- **不支持多线程 WASM**:微信小程序不支持 `SharedArrayBuffer`,必须使用单线程版本 +- **包体积限制**:单个分包不超过 2MB,总包不超过 20MB +- **域名白名单**:需在小程序后台配置 `downloadFile` 合法域名 +- **内存限制**:iOS 约 300MB,Android 约 500MB +- **文件大小建议**:PAGX 文件不超过 10MB + +## 调试技巧 + +```javascript +// 在 onLoad 中添加 +console.log('DPR:', this.dpr); +console.log('Canvas size:', this.canvas.width, 'x', this.canvas.height); +console.log('Content size:', this.View.contentWidth(), 'x', this.View.contentHeight()); + +// 监控渲染帧率 +let frameCount = 0; +let lastTime = Date.now(); +this.View.setRenderCallbacks(null, () => { + frameCount++; + const now = Date.now(); + if (now - lastTime >= 1000) { + console.log('FPS:', frameCount); + frameCount = 0; + lastTime = now; + } +}); +``` + +## 相关资源 + +- [PAGX Viewer 文档](https://pag.io/pagx/1.4/zh/) +- [PAG 官网](https://pag.io) +- [libpag GitHub](https://github.com/Tencent/libpag) +- [TGFX 项目](https://github.com/Tencent/tgfx) +- [微信小程序官方文档](https://developers.weixin.qq.com/miniprogram/dev/framework/) +- [WXWebAssembly API](https://developers.weixin.qq.com/miniprogram/dev/framework/performance/wasm.html) diff --git a/pagx/wechat/README.md b/pagx/wechat/README.md new file mode 100644 index 0000000000..5c079cb809 --- /dev/null +++ b/pagx/wechat/README.md @@ -0,0 +1,140 @@ +[English](./README.md) | [中文](./README.zh_CN.md) + +# PAGX Viewer for WeChat Mini Program + +A WeChat Mini Program runtime for rendering PAGX files. The C++ rendering core +is compiled to WebAssembly via Emscripten, and the TypeScript bindings are +bundled into a single ESM module that can be consumed by a mini program. + +## Directory Layout + +``` +pagx/wechat/ +├── CMakeLists.txt # CMake entry for building the wasm module +├── src/ # C++ sources bound to JS (PAGXView, GridBackground, binding.cpp) +├── ts/ # TypeScript sources (pagx.ts, pagx-view.ts, gesture-manager.ts, ...) +├── script/ # Build scripts (cmake / rollup / emsdk setup / file copy) +├── wasm/ # Raw CMake output (pagx-viewer.wasm, pagx-viewer.js) +├── lib/ # Final bundled JS + compressed wasm (.br) +├── wx_demo/ # Sample WeChat Mini Program consuming the viewer +├── GETTING_STARTED.md # Quick-start guide and full API reference for consumers +├── CHANGELOG.md # Release history +└── package.json # Build config + npm publish config +``` + +## Prerequisites + +- **Node.js** 16 or later, with `npm` +- **CMake** 3.13+ and **Ninja** (used by the CMake wrapper script) +- **Brotli** CLI on `PATH` (for compressing the final wasm) + - macOS: `brew install brotli` + - Debian/Ubuntu: `sudo apt-get install brotli` +- A C/C++ toolchain available on the host (for any native tooling invoked + during the CMake configure step) + +You do **not** need to install Emscripten manually. The build script clones +and activates `emsdk 3.1.20` automatically into +`third_party/emsdk` the first time it runs. + +Before building, make sure the libpag third-party dependencies have been +synced from the repository root: + +```bash +cd /path/to/libpag +./sync_deps.sh +``` + +## One-Shot Build + +From this directory (`pagx/wechat/`): + +```bash +npm install +npm run build +``` + +This single command runs the full pipeline: + +1. `node script/cmake.wx.js -a wasm` — configures and builds + `pagx-viewer.wasm` / `pagx-viewer.js` via Emscripten + CMake. Output is + placed under `wasm/` and then copied to `lib/`. +2. `brotli -f ./lib/pagx-viewer.wasm` — produces `lib/pagx-viewer.wasm.br`. +3. `rollup -c ./script/rollup.wx.js` — bundles the TypeScript sources + (`ts/pagx.ts`, `ts/gesture-manager.ts`) into `lib/pagx-viewer.js` and + `wx_demo/utils/gesture-manager.js`. +4. `node script/copy-files.js` — copies `lib/pagx-viewer.js` and + `lib/pagx-viewer.wasm.br` into `wx_demo/utils/` so the demo mini program + picks up the fresh build. +5. `tsc -p ./tsconfig.type.json` — emits `.d.ts` type definitions. + +## Build Individual Steps + +Only rebuild the wasm module: + +```bash +node script/cmake.wx.js -a wasm +``` + +Only rebuild the JS bundle (no wasm): + +```bash +npm run build:js +``` + +Clean wasm build cache (forces a full rebuild next time): + +```bash +npm run clean +``` + +## Output Artifacts + +After a successful build, the distributable files are: + +| File | Purpose | +|------|---------| +| `lib/pagx-viewer.js` | Bundled ESM glue + TypeScript bindings (core renderer) | +| `lib/pagx-viewer.wasm` | Uncompressed WebAssembly module | +| `lib/pagx-viewer.wasm.br` | Brotli-compressed wasm shipped to the mini program | +| `lib/pagx-check.js` | Standalone render-risk evaluator (optional, see below) | +| `wx_demo/utils/pagx-viewer.js` | Demo copy, kept in sync by `copy-files.js` | +| `wx_demo/utils/pagx-viewer.wasm.br` | Demo copy, kept in sync by `copy-files.js` | + +### `pagx-check.js` — Render Risk Evaluator + +`pagx-check.js` is an **optional** standalone module that evaluates whether a +PAGX file is likely to render smoothly on the current device. It is **not** +required for rendering and does not depend on the WASM module or WebGL context. + +Hosts that want pre-render stutter detection can import it separately: + +```javascript +const { CheckPagx } = require('./pagx-check'); +const result = await CheckPagx(pagxFileBytes); +// result.score >= threshold means smooth rendering is expected +``` + +If your application does not need the risk check, simply omit `pagx-check.js` +from your deployment — it will not affect rendering functionality. + +## Running the Demo + +`wx_demo/` is a minimal WeChat Mini Program that consumes the built viewer. +Open the directory in WeChat Developer Tools (微信开发者工具) and click +**Run**. Any time you rebuild with `npm run build`, the demo's +`utils/` folder is refreshed automatically. + +## Troubleshooting + +- **`emsdk` clone/update fails** — the script requires network access to + `github.com/emscripten-core/emsdk`. If you are behind a firewall, clone + `emsdk` manually to `third_party/emsdk` and let the script activate + version 3.1.20. +- **`brotli: command not found`** — install the Brotli CLI (see + [Prerequisites](#prerequisites)). +- **Wasm rebuild is skipped** — delete `.pagx-viewer.wasm.md5` and the + build directory under `wasm/` (or run `npm run clean`) to force a rebuild. +- **`tgfx` not found** — make sure `./sync_deps.sh` has been run at the + libpag repository root so that `third_party/tgfx` exists. You can also + override its location via `-DTGFX_DIR=/path/to/tgfx` when invoking CMake + directly. diff --git a/pagx/wechat/README.zh_CN.md b/pagx/wechat/README.zh_CN.md new file mode 100644 index 0000000000..fee0859e8a --- /dev/null +++ b/pagx/wechat/README.zh_CN.md @@ -0,0 +1,132 @@ +[English](./README.md) | [中文](./README.zh_CN.md) + +# PAGX Viewer 微信小程序版 + +用于在微信小程序中渲染 PAGX 文件的运行时。C++ 渲染核心通过 Emscripten +编译为 WebAssembly,TypeScript 绑定代码打包为单一 ESM 模块,供小程序直接 +引入使用。 + +## 目录结构 + +``` +pagx/wechat/ +├── CMakeLists.txt # 构建 wasm 模块的 CMake 入口 +├── src/ # 绑定到 JS 的 C++ 源码(PAGXView、GridBackground、binding.cpp) +├── ts/ # TypeScript 源码(pagx.ts、pagx-view.ts、gesture-manager.ts 等) +├── script/ # 构建脚本(CMake / Rollup / emsdk 环境准备 / 产物拷贝) +├── wasm/ # CMake 原始产物(pagx-viewer.wasm、pagx-viewer.js) +├── lib/ # 打包后的 JS 与压缩 wasm(.br) +├── wx_demo/ # 使用 viewer 的小程序示例 +├── GETTING_STARTED.md # 快速开始指南与完整 API 参考 +├── CHANGELOG.md # 版本发布历史 +└── package.json # 构建配置 + npm 发布配置 +``` + +## 环境要求 + +- **Node.js** 16 及以上,含 `npm` +- **CMake** 3.13+ 与 **Ninja**(供 CMake 包装脚本使用) +- **Brotli** 命令行工具需在 `PATH` 中(用于压缩最终的 wasm) + - macOS:`brew install brotli` + - Debian/Ubuntu:`sudo apt-get install brotli` +- 主机上需具备可用的 C/C++ 工具链(供 CMake 配置阶段使用) + +**无需手动安装 Emscripten**。首次运行构建脚本时会自动将 `emsdk 3.1.20` +克隆并激活到 `third_party/emsdk`。 + +构建前请先在 libpag 仓库根目录执行依赖同步脚本: + +```bash +cd /path/to/libpag +./sync_deps.sh +``` + +## 一键构建 + +在当前目录(`pagx/wechat/`)执行: + +```bash +npm install +npm run build +``` + +这条命令会依次执行完整流水线: + +1. `node script/cmake.wx.js -a wasm` — 通过 Emscripten + CMake 构建 + `pagx-viewer.wasm` / `pagx-viewer.js`,产物输出到 `wasm/` 并拷贝到 + `lib/`。 +2. `brotli -f ./lib/pagx-viewer.wasm` — 生成 `lib/pagx-viewer.wasm.br`。 +3. `rollup -c ./script/rollup.wx.js` — 将 TypeScript 源码(`ts/pagx.ts`、 + `ts/gesture-manager.ts`)打包为 `lib/pagx-viewer.js` 和 + `wx_demo/utils/gesture-manager.js`。 +4. `node script/copy-files.js` — 把 `lib/pagx-viewer.js` 与 + `lib/pagx-viewer.wasm.br` 拷贝到 `wx_demo/utils/`,让示例小程序用上 + 最新产物。 +5. `tsc -p ./tsconfig.type.json` — 输出 `.d.ts` 类型声明。 + +## 分步构建 + +只重新编译 wasm: + +```bash +node script/cmake.wx.js -a wasm +``` + +只重新打包 JS(不编译 wasm): + +```bash +npm run build:js +``` + +清理 wasm 构建缓存(强制下次完整重编): + +```bash +npm run clean +``` + +## 产物说明 + +构建成功后,可分发的文件如下: + +| 文件 | 说明 | +|------|------| +| `lib/pagx-viewer.js` | 打包后的 ESM 胶水层 + TypeScript 绑定(核心渲染器) | +| `lib/pagx-viewer.wasm` | 未压缩的 WebAssembly 模块 | +| `lib/pagx-viewer.wasm.br` | 随小程序分发的 Brotli 压缩版 wasm | +| `lib/pagx-check.js` | 独立的渲染风险评估工具(可选,见下文) | +| `wx_demo/utils/pagx-viewer.js` | 示例小程序副本,由 `copy-files.js` 自动同步 | +| `wx_demo/utils/pagx-viewer.wasm.br` | 示例小程序副本,由 `copy-files.js` 自动同步 | + +### `pagx-check.js` — 渲染风险评估工具 + +`pagx-check.js` 是一个**可选的**独立模块,用于评估当前设备能否流畅渲染指定的 +PAGX 文件。它**不是渲染必需依赖**,不依赖 WASM 模块或 WebGL 上下文。 + +需要预渲染卡顿检测的业务可单独引入: + +```javascript +const { CheckPagx } = require('./pagx-check'); +const result = await CheckPagx(pagxFileBytes); +// result.score >= 阈值表示预期可流畅渲染 +``` + +如果你的应用不需要风险评估,部署时可以省略 `pagx-check.js`,不会影响渲染功能。 + +## 运行示例 + +`wx_demo/` 是使用该 viewer 的最小示例小程序。用微信开发者工具打开该 +目录并点击 **运行** 即可。每次执行 `npm run build` 都会自动刷新 +demo 的 `utils/` 目录。 + +## 常见问题 + +- **`emsdk` 克隆或更新失败**:脚本需要访问 + `github.com/emscripten-core/emsdk`。如处于受限网络环境,可先手动将 + `emsdk` 克隆到 `third_party/emsdk`,脚本会识别并激活 3.1.20 版本。 +- **`brotli: command not found`**:请按上文 [环境要求](#环境要求) 安装 + Brotli 命令行工具。 +- **wasm 似乎没有重新编译**:删除 `.pagx-viewer.wasm.md5` 以及 `wasm/` + 下的构建目录(或执行 `npm run clean`)可强制重新编译。 +- **找不到 `tgfx`**:确保已在 libpag 根目录执行过 `./sync_deps.sh`,使 + `third_party/tgfx` 存在。也可直接调用 CMake 时通过 + `-DTGFX_DIR=/path/to/tgfx` 指定位置。 diff --git a/pagx/wechat/package.json b/pagx/wechat/package.json new file mode 100644 index 0000000000..20f14ba2de --- /dev/null +++ b/pagx/wechat/package.json @@ -0,0 +1,41 @@ +{ + "name": "@tencent/pagx-viewer-miniprogram", + "version": "1.9.6", + "description": "PAGX Viewer for WeChat Miniprogram", + "type": "module", + "main": "lib/pagx-viewer.js", + "typings": "types/pagx/wechat/ts/pagx.d.ts", + "files": [ + "lib", + "types", + "CHANGELOG.md", + "GETTING_STARTED.md" + ], + "scripts": { + "clean": "rimraf --glob .pagx-viewer.wasm.md5", + "build:js": "rollup -c ./script/rollup.wx.js && node script/copy-files.js && tsc -p ./tsconfig.type.json ", + "build": "node script/cmake.wx.js -a wasm && brotli -f ./lib/pagx-viewer.wasm && npm run build:js", + "build:release": "node script/cmake.wx.js -a wasm -DNO_LOG=ON && brotli -f ./lib/pagx-viewer.wasm && npm run build:js", + "copyFile": "node script/copy-files.js" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "~28.0.3", + "@rollup/plugin-json": "~6.1.0", + "@rollup/plugin-node-resolve": "~16.0.1", + "@rollup/plugin-alias": "^5.0.0", + "@types/emscripten": "~1.39.6", + "esbuild": "~0.15.14", + "rimraf": "~5.0.10", + "rollup": "~2.79.1", + "rollup-plugin-esbuild": "~4.10.3", + "rollup-plugin-terser": "~7.0.2", + "tslib": "~2.4.1", + "typescript": "~5.0.3" + }, + "dependencies": { + "express": "^4.21.1" + }, + "license": "BSD-3-Clause", + "author": "Tencent", + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" +} diff --git a/pagx/wechat/pagx-viewer.hash b/pagx/wechat/pagx-viewer.hash new file mode 100644 index 0000000000..0f563b5376 --- /dev/null +++ b/pagx/wechat/pagx-viewer.hash @@ -0,0 +1,7 @@ +CMakeLists.txt +src/ +../../src/pagx/ +../../src/renderer/ +../../include/pagx/ +../../third_party/tgfx/src/ +../../third_party/tgfx/include/ diff --git a/pagx/wechat/script/cmake.wx.js b/pagx/wechat/script/cmake.wx.js new file mode 100644 index 0000000000..3649451c70 --- /dev/null +++ b/pagx/wechat/script/cmake.wx.js @@ -0,0 +1,55 @@ +#!/usr/bin/env node +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making tgfx available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the BSD 3-Clause License (the "License"); you may not use this file except +// in compliance with the License. You may obtain a copy of the License at +// +// https://opensource.org/licenses/BSD-3-Clause +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +import { fileURLToPath } from 'url'; +import path from 'path'; +import fs from "fs"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +process.chdir(__dirname); + +process.argv.push("-s"); +process.argv.push("../"); +process.argv.push("-o"); +process.argv.push("../"); +process.argv.push("-p"); +process.argv.push("web"); +process.argv.push("pagx-viewer"); + +await import('./setup.emsdk.wx.js'); +await import("../../../third_party/vendor_tools/lib-build"); + + +if (!fs.existsSync("../lib")) { + fs.mkdirSync("../lib", {recursive: true}); +} + +const wasmFile = "../wasm/pagx-viewer.wasm"; +if (!fs.existsSync(wasmFile)) { + console.error("WASM file not found. CMake build may have failed:", wasmFile); + process.exit(1); +} +try { + fs.copyFileSync(wasmFile, "../lib/pagx-viewer.wasm"); +} catch (error) { + console.error("Failed to copy WASM file to lib:", error); + process.exit(1); +} diff --git a/pagx/wechat/script/copy-files.js b/pagx/wechat/script/copy-files.js new file mode 100644 index 0000000000..ed052b2248 --- /dev/null +++ b/pagx/wechat/script/copy-files.js @@ -0,0 +1,40 @@ +#!/usr/bin/env node + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +/** + * Copy wasm and js build artifacts from lib/ to wx_demo/utils/ + */ +function copyFiles() { + const sourceDir = path.resolve(__dirname, '../lib'); + const targetDir = path.resolve(__dirname, '../wx_demo/utils'); + + if (!fs.existsSync(targetDir)) { + fs.mkdirSync(targetDir, { recursive: true }); + } + + const files = ['pagx-check.js','pagx-viewer.js', 'pagx-viewer.wasm.br']; + const missing = []; + for (const file of files) { + const src = path.join(sourceDir, file); + const dst = path.join(targetDir, file); + if (!fs.existsSync(src)) { + missing.push(src); + console.error(`Error: source file not found: ${src}`); + continue; + } + fs.copyFileSync(src, dst); + console.log(`Copied ${file}`); + } + if (missing.length > 0) { + console.error(`${missing.length} required file(s) missing, aborting.`); + process.exit(1); + } +} + +copyFiles(); diff --git a/pagx/wechat/script/plugin/replace-config.js b/pagx/wechat/script/plugin/replace-config.js new file mode 100644 index 0000000000..26f8051655 --- /dev/null +++ b/pagx/wechat/script/plugin/replace-config.js @@ -0,0 +1,318 @@ +/* eslint-disable */ +export const replaceFunctionConfig = [ + { + name: 'replace __emval_get_method_caller', + start: 'function __emval_get_method_caller(argCount, argTypes)', + end: 'function __emval_get_module_property(name)', + type: 'function', + replaceStr: function __emval_get_method_caller(argCount, argTypes) { + var types; + try { + types = __emval_lookupTypes(argCount, argTypes); + } catch (e) { + types = emval_lookupTypes(argCount, argTypes); + } + var retType = types[0]; + var signatureName = + retType.name + + '_$' + + types + .slice(1) + .map(function (t) { + return t.name; + }) + .join('_') + + '$'; + var returnId = emval_registeredMethods[signatureName]; + if (returnId !== void 0) { + return returnId; + } + var params = ['retType']; + var args = [retType]; + var argsList = ''; + for (var i2 = 0; i2 < argCount - 1; ++i2) { + argsList += (i2 !== 0 ? ', ' : '') + 'arg' + i2; + params.push('argType' + i2); + args.push(types[1 + i2]); + } + var functionName = makeLegalFunctionName('methodCaller_' + signatureName); + var functionBody = 'return function ' + functionName + '(handle, name, destructors, args) {\n'; + var offset = 0; + for (var i2 = 0; i2 < argCount - 1; ++i2) { + functionBody += + ' var arg' + i2 + ' = argType' + i2 + '.readValueFromPointer(args' + (offset ? '+' + offset : '') + ');\n'; + offset += types[i2 + 1]['argPackAdvance']; + } + functionBody += ' var rv = handle[name](' + argsList + ');\n'; + for (var i2 = 0; i2 < argCount - 1; ++i2) { + if (types[i2 + 1]['deleteObject']) { + functionBody += ' argType' + i2 + '.deleteObject(arg' + i2 + ');\n'; + } + } + if (!retType.isVoid) { + functionBody += ' return retType.toWireType(destructors, rv);\n'; + } + functionBody += '};\n'; + params.push(functionBody); + var anonymous = function (retType) { + var parentargs = Array.from(arguments); + parentargs.shift(); + return (this[makeLegalFunctionName('methodCaller_' + signatureName)] = function ( + handle, + name, + destructors, + args, + ) { + var paramList = []; + var offset = 0; + for (var i = 0; i < parentargs.length; i++) { + paramList.push(parentargs[i].readValueFromPointer(args + offset)); + offset += types[i + 1]['argPackAdvance']; + } + var rv = handle[name](...paramList); + if (!retType.isVoid) { + return retType.toWireType(destructors, rv); + } + }); + }; + var invokerFunction = anonymous.apply({}, args); + try { + returnId = __emval_addMethodCaller(invokerFunction); + } catch (e) { + returnId = emval_addMethodCaller(invokerFunction); + } + emval_registeredMethods[signatureName] = returnId; + return returnId; + }.toString(), + }, + { + name: 'replace craftEmvalAllocator', + start: 'function craftEmvalAllocator(argCount)', + end: 'var emval_newers = {};', + type: 'function', + replaceStr: function craftEmvalAllocator(argCount) { + var argsList = ''; + for (var i2 = 0; i2 < argCount; ++i2) { + argsList += (i2 !== 0 ? ', ' : '') + 'arg' + i2; + } + var functionBody = 'return function emval_allocator_' + argCount + '(constructor, argTypes, args) {\n'; + for (var i2 = 0; i2 < argCount; ++i2) { + functionBody += + 'var argType' + + i2 + + " = requireRegisteredType(Module['HEAP32'][(argTypes >>> 2) + " + + i2 + + '], "parameter ' + + i2 + + '");\nvar arg' + + i2 + + ' = argType' + + i2 + + '.readValueFromPointer(args);\nargs += argType' + + i2 + + "['argPackAdvance'];\n"; + } + functionBody += 'var obj = new constructor(' + argsList + ');\nreturn valueToHandle(obj);\n}\n'; + function anonymous(requireRegisteredType, Module, valueToHandle) { + return function (constructor, argTypes, args) { + var resultList = []; + for (var i2 = 0; i2 < argCount; ++i2) { + var currentArg = requireRegisteredType(Module['HEAP32'][(argTypes >>> 2) + i2], `parameter ${i2}`); + var res = currentArg.readValueFromPointer(args); + resultList.push(res); + args += currentArg['argPackAdvance']; + } + var obj = new constructor(...resultList); + return valueToHandle(obj); + }; + } + var invokerFunction = anonymous.apply({}, [requireRegisteredType, Module, Emval.toHandle]); + return invokerFunction; + }.toString(), + }, + { + name: 'replace craftInvokerFunction', + start: 'function craftInvokerFunction(humanName, argTypes, classType, cppInvokerFunc, cppTargetFunc)', + end: 'function heap32VectorToArray(count, firstElement)', + type: 'function', + replaceStr: function craftInvokerFunction(humanName, argTypes, classType, cppInvokerFunc, cppTargetFunc) { + var createOption = {}; + createOption.argCount = argTypes.length; + var argCount = argTypes.length; + if (argCount < 2) { + throwBindingError("argTypes array size mismatch! Must at least get return value and 'this' types!"); + } + createOption.isClassMethodFunc = argTypes[1] !== null && classType !== null; + var isClassMethodFunc = argTypes[1] !== null && classType !== null; + var needsDestructorStack = false; + for (var i2 = 1; i2 < argTypes.length; ++i2) { + if (argTypes[i2] !== null && argTypes[i2].destructorFunction === void 0) { + needsDestructorStack = true; + break; + } + } + createOption.needsDestructorStack = needsDestructorStack; + var returns = argTypes[0].name !== 'void'; + createOption.returns = argTypes[0].name !== 'void'; + createOption.childArgs = []; + createOption.childDtorFunc = []; + for (var i2 = 0; i2 < argCount - 2; ++i2) { + createOption.childArgs.push(i2); + } + var args1 = ['throwBindingError', 'invoker', 'fn', 'runDestructors', 'retType', 'classParam']; + var args2 = [throwBindingError, cppInvokerFunc, cppTargetFunc, runDestructors, argTypes[0], argTypes[1]]; + for (var i2 = 0; i2 < argCount - 2; ++i2) { + args1.push('argType' + i2); + args2.push(argTypes[i2 + 2]); + } + if (!needsDestructorStack) { + for (var i2 = isClassMethodFunc ? 1 : 2; i2 < argTypes.length; ++i2) { + var paramName = i2 === 1 ? 'thisWired' : 'arg' + (i2 - 2) + 'Wired'; + if (argTypes[i2].destructorFunction !== null) { + createOption.childDtorFunc.push({ + paramName, + func: argTypes[i2].destructorFunction, + index: i2, + }); + } + } + } + function anonymous(throwBindingError, invoker, fn, runDestructors, retType, classParam) { + const anonymousArg = Array.from(arguments); + + return (this[makeLegalFunctionName(humanName)] = function () { + var parentargs = Array.from(arguments); + var argumentLen = createOption.childArgs.length; + if (parentargs.length !== argCount - 2) { + throwBindingError( + 'function _PAGPlayer._getComposition called with ' + parentargs.length + ' arguments, expected 0 args!', + ); + } + const argArr = anonymousArg.slice(6, 6 + argumentLen); + var destructors = []; + var dtorStack = needsDestructorStack ? destructors : null; + var thisWired; + var argWiredList = []; + var rv; + if (isClassMethodFunc) { + thisWired = classParam.toWireType(dtorStack, this); + } + for (var i = 0; i < createOption.childArgs.length; i++) { + argWiredList.push(argArr[i].toWireType(dtorStack, parentargs[i])); + } + if (isClassMethodFunc) { + rv = invoker(fn, thisWired, ...argWiredList); + } else { + rv = invoker(fn, ...argWiredList); + } + function onDone(crv) { + if (needsDestructorStack) { + runDestructors(destructors); + } else { + const funcOption = createOption.childDtorFunc; + for (var i = 0; i < funcOption.length; i++) { + const currentOption = funcOption[i]; + if (currentOption.index === 1) { + currentOption.func(thisWired); + } else { + const ci = createOption.childArgs.indexOf(currentOption.index); + if (ci >= 0) { + currentOption.func(argWiredList[ci]); + } + } + } + } + if (returns) { + var ret = retType.fromWireType(crv); + return ret; + } + } + return onDone(rv); + }); + } + var invokerFunction = anonymous.apply({}, args2); + return invokerFunction; + }.toString(), + }, + { + name: 'replace createNamedFunction', + start: 'function createNamedFunction(name, body)', + end: 'function extendError(baseErrorType, errorName)', + replaceStr: function createNamedFunction(name, body) { + name = makeLegalFunctionName(name); + return function () { + return body.apply(this, arguments); + }; + }.toString(), + }, + { + name: 'replace getBinaryPromise', + start: 'function getBinaryPromise()', + end: 'function createWasm()', + replaceStr: function getBinaryPromise() { + return new Promise((resolve, reject) => { + if (globalThis.isWxWebAssembly) { + resolve(wasmBinaryFile); + } else { + const fs = wx.getFileSystemManager(); + fs.readFile({ + filePath: wasmBinaryFile, + position: 0, + success(res) { + resolve(res.data); + }, + fail(res) { + reject(res); + }, + }); + } + }); + }.toString(), + }, + { + name: 'replace WebAssembly Runtime error', + type: 'string', + start: 'var e = new WebAssembly.RuntimeError(what);', + replaceStr: 'var e = "run time error";', + }, + { + name: 'replace performance', + type: 'string', + start: 'performance.now();', + replaceStr: 'Date.now();', + }, + { + name: 'replace pagx-viewer.wasm name', + start: 'var wasmBinaryFile;', + end: 'function getBinary(file)', + replaceStr:` + var wasmBinaryFile; + wasmBinaryFile = "pagx-viewer.wasm.br"; + if (!isDataURI(wasmBinaryFile)) { + wasmBinaryFile = locateFile(wasmBinaryFile); + } + `, + }, + { + name: 'fix get gl get framebuffer', + type: 'string', + start: 'var result = GLctx.getParameter(name_);', + replaceStr: `var result = GLctx.getParameter(name_); + if(result === null || result === undefined) { + return 0; + } + `, + }, + { + name: 'fix gl initExtensions', + type: 'string', + start: `if (!ext.includes("lose_context") && !ext.includes("debug"))`, + replaceStr: `if (!ext.includes("lose_context") && !ext.includes("debug") && !ext.includes("WEBGL_webcodecs_video_frame"))`, + }, + { + name: 'replace _scriptDir', + type: 'string', + start: `var _scriptDir = import_meta.url;`, + replaceStr: `var _scriptDir = typeof document !== "undefined" && document.currentScript ? document.currentScript.src : void 0;`, + }, +]; diff --git a/pagx/wechat/script/plugin/rollup-plugin-replace.js b/pagx/wechat/script/plugin/rollup-plugin-replace.js new file mode 100644 index 0000000000..96864e917a --- /dev/null +++ b/pagx/wechat/script/plugin/rollup-plugin-replace.js @@ -0,0 +1,30 @@ +import MagicString from 'magic-string'; +import { replaceFunctionConfig } from './replace-config'; + +export default function replaceFunc() { + return { + name: 'replaceFunc', + transform(code, id) { + let codeStr = `${code}`; + const magic = new MagicString(codeStr); + replaceFunctionConfig.forEach((item) => { + if (item.type === 'string') { + const startOffset = codeStr.indexOf(item.start); + if (startOffset > -1) { + magic.overwrite(startOffset, startOffset + item.start.length, item.replaceStr); + } + } else { + const startOffset = codeStr.indexOf(item.start); + const endOffset = codeStr.indexOf(item.end); + if (startOffset > -1 && endOffset > startOffset && item.replaceStr) { + magic.overwrite(startOffset, endOffset, item.replaceStr); + } + } + }); + return { + code: magic.toString(), + map: magic.generateMap({ hires: true }), + }; + }, + }; +} diff --git a/pagx/wechat/script/rollup.wx.js b/pagx/wechat/script/rollup.wx.js new file mode 100644 index 0000000000..ede467be34 --- /dev/null +++ b/pagx/wechat/script/rollup.wx.js @@ -0,0 +1,88 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making tgfx available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the BSD 3-Clause License (the "License"); you may not use this file except +// in compliance with the License. You may obtain a copy of the License at +// +// https://opensource.org/licenses/BSD-3-Clause +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +import esbuild from 'rollup-plugin-esbuild'; +import resolve from '@rollup/plugin-node-resolve'; +import commonJs from '@rollup/plugin-commonjs'; +import json from '@rollup/plugin-json'; +import alias from '@rollup/plugin-alias'; +import path from "path"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from 'url'; +import replaceFunc from './plugin/rollup-plugin-replace'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const fileHeaderPath = path.resolve(__dirname, '../../../.idea/fileTemplates/includes/PAG File Header.h'); +const banner = readFileSync(fileHeaderPath, 'utf-8'); +const isRelease = process.env.BUILD_MODE === 'release'; + +const plugins = [ + esbuild({ tsconfig: path.resolve(__dirname, "../tsconfig.json"), minify: isRelease }), + json(), + alias({ + entries: [{ find: '@tgfx', replacement: path.resolve(__dirname, '../../../third_party/tgfx/web/src') }], + }), + resolve({ extensions: ['.mjs', '.js', '.ts', '.json'] }), + commonJs(), + replaceFunc(), + { + name: 'preserve-import-meta-url', + resolveImportMeta(property, options) { + // Preserve the original behavior of `import.meta.url`. + if (property === 'url') { + return 'import.meta.url'; + } + return null; + }, + }, +]; + +export default [ + { + input: path.resolve(__dirname, '../ts/pagx.ts'), + output: { + banner, + file: path.resolve(__dirname, '../lib/pagx-viewer.js'), + format: 'esm', + sourcemap: !isRelease + }, + plugins: plugins, + }, + { + input: path.resolve(__dirname, '../ts/pagx-check.ts'), + output: { + banner, + file: path.resolve(__dirname, '../lib/pagx-check.js'), + format: 'esm', + sourcemap: !isRelease + }, + plugins: plugins, + }, + { + input: path.resolve(__dirname, '../ts/gesture-manager.ts'), + output: { + banner, + file: path.resolve(__dirname, '../wx_demo/utils/gesture-manager.js'), + format: 'esm', + sourcemap: !isRelease + }, + plugins: plugins, + } +]; diff --git a/pagx/wechat/script/setup.emsdk.wx.js b/pagx/wechat/script/setup.emsdk.wx.js new file mode 100644 index 0000000000..ad39885155 --- /dev/null +++ b/pagx/wechat/script/setup.emsdk.wx.js @@ -0,0 +1,48 @@ +/* +* WeChat Mini Program compilation depends on emsdk version 3.1.20. +* This script is used to download and activate the specified version of emsdk. +* +* WARNING: Do NOT upgrade the emsdk version without thorough testing on real devices. +* WeChat's WXWebAssembly runtime has limited compatibility with Emscripten's generated +* JS glue code. Versions above 3.1.20 produce API calls (e.g. newer TextDecoder usage, +* BigInt integration, or ES module patterns) that WXWebAssembly does not support, +* causing silent failures or crashes at runtime. This version is a hard constraint +* determined by the WeChat Mini Program platform, not a development preference. +*/ + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import Utils from "../../../third_party/vendor_tools/lib/Utils.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const emsdkPath = path.resolve(__dirname, '../../../third_party/emsdk'); +if (!fs.existsSync(emsdkPath)) { + try { + Utils.exec(`git clone https://github.com/emscripten-core/emsdk.git ${emsdkPath}`); + } catch (error) { + console.error('clone emsdk failed:', error); + process.exit(1); + } +} else { + Utils.exec(`git -C ${emsdkPath} pull`); +} +const emscriptenPath = path.resolve(emsdkPath, 'upstream/emscripten'); +process.env.PATH = process.platform === 'win32' + ? `${emsdkPath};${emscriptenPath};${process.env.PATH}` + : `${emsdkPath}:${emscriptenPath}:${process.env.PATH}`; +Utils.exec("emsdk install 3.1.20", emsdkPath); +Utils.exec("emsdk activate 3.1.20", emsdkPath); +const emsdkEnv = process.platform === 'win32' ? "emsdk_env.bat" : ". emsdk_env.sh"; +let result = Utils.execSafe(emsdkEnv, emsdkPath); +let lines = result.split("\n"); +for (let line of lines) { + let values = line.split("="); + if (values.length > 1) { + process.stdout.write(line); + let key = values[0].trim(); + process.env[key] = values[1].trim(); + } +} diff --git a/pagx/wechat/src/GridBackground.cpp b/pagx/wechat/src/GridBackground.cpp new file mode 100644 index 0000000000..aacdf4bab2 --- /dev/null +++ b/pagx/wechat/src/GridBackground.cpp @@ -0,0 +1,75 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#include "GridBackground.h" +#include "tgfx/core/Canvas.h" +#include "tgfx/core/Paint.h" +#include "tgfx/layers/LayerRecorder.h" + +namespace pagx { + +std::shared_ptr GridBackgroundLayer::Make(int width, int height, + float density) { + return std::shared_ptr(new GridBackgroundLayer(width, height, density)); +} + +GridBackgroundLayer::GridBackgroundLayer(int width, int height, float density) + : gridWidth(width), gridHeight(height), density(density) { + invalidateContent(); +} + +void GridBackgroundLayer::onUpdateContent(tgfx::LayerRecorder* recorder) { + tgfx::LayerPaint backgroundPaint(tgfx::Color::White()); + recorder->addRect( + tgfx::Rect::MakeWH(static_cast(gridWidth), static_cast(gridHeight)), + backgroundPaint); + + tgfx::LayerPaint tilePaint(tgfx::Color{0.8f, 0.8f, 0.8f, 1.f}); + // Use fixed logical size (32px) so the grid looks the same on all screens. + int logicalTileSize = 32; + int tileSize = static_cast(static_cast(logicalTileSize) * density); + if (tileSize <= 0) { + tileSize = logicalTileSize; + } + for (int y = 0; y < gridHeight; y += tileSize) { + bool draw = (y / tileSize) % 2 == 1; + for (int x = 0; x < gridWidth; x += tileSize) { + if (draw) { + recorder->addRect( + tgfx::Rect::MakeXYWH(static_cast(x), static_cast(y), + static_cast(tileSize), static_cast(tileSize)), + tilePaint); + } + draw = !draw; + } + } +} + +void DrawBackground(tgfx::Canvas* canvas, int width, int height, float density) { + auto layer = GridBackgroundLayer::Make(width, height, density); + layer->draw(canvas); +} + +void DrawSolidBackground(tgfx::Canvas* canvas, int width, int height, const tgfx::Color& color) { + tgfx::Paint paint = {}; + paint.setColor(color); + canvas->drawRect(tgfx::Rect::MakeWH(static_cast(width), static_cast(height)), + paint); +} + +} // namespace pagx diff --git a/pagx/wechat/src/GridBackground.h b/pagx/wechat/src/GridBackground.h new file mode 100644 index 0000000000..62b526c489 --- /dev/null +++ b/pagx/wechat/src/GridBackground.h @@ -0,0 +1,45 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include "tgfx/layers/Layer.h" +#include "tgfx/core/Color.h" + +namespace pagx { + +class GridBackgroundLayer : public tgfx::Layer { + public: + static std::shared_ptr Make(int width, int height, float density); + + protected: + void onUpdateContent(tgfx::LayerRecorder* recorder) override; + + private: + GridBackgroundLayer(int width, int height, float density); + + int gridWidth = 0; + int gridHeight = 0; + float density = 1.f; +}; + +void DrawBackground(tgfx::Canvas* canvas, int width, int height, float density); + +void DrawSolidBackground(tgfx::Canvas* canvas, int width, int height, const tgfx::Color& color); + +} // namespace pagx diff --git a/pagx/wechat/src/PAGXImageResourceProvider.h b/pagx/wechat/src/PAGXImageResourceProvider.h new file mode 100644 index 0000000000..2ac69bac0b --- /dev/null +++ b/pagx/wechat/src/PAGXImageResourceProvider.h @@ -0,0 +1,86 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include +#include +#include +#include "pagx/ImageResourceProvider.h" +#include "tgfx/core/Image.h" + +namespace pagx { + +/** + * WeChat MiniProgram image resource provider implementing progressive loading with two-tier + * caching (full-quality + thumbnail). Returns the best available quality when queried by the + * renderer: full-quality first, thumbnail as fallback, nullptr if neither is available. + */ +class PAGXImageResourceProvider : public ImageResourceProvider { + public: + std::shared_ptr resolveImage(const std::string& filePath) override { + auto fullIt = fullImages.find(filePath); + if (fullIt != fullImages.end()) { + return fullIt->second; + } + auto thumbIt = thumbnailImages.find(filePath); + if (thumbIt != thumbnailImages.end()) { + return thumbIt->second; + } + return nullptr; + } + + bool hasImage(const std::string& filePath) const override { + return fullImages.count(filePath) > 0 || thumbnailImages.count(filePath) > 0; + } + + void putFullImage(const std::string& filePath, std::shared_ptr image) { + fullImages[filePath] = std::move(image); + } + + void putThumbnailImage(const std::string& filePath, std::shared_ptr image) { + thumbnailImages[filePath] = std::move(image); + } + + void clearFullImage(const std::string& filePath) { + fullImages.erase(filePath); + } + + void clearThumbnailImage(const std::string& filePath) { + thumbnailImages.erase(filePath); + } + + bool hasFullImage(const std::string& filePath) const { + return fullImages.count(filePath) > 0; + } + + bool hasThumbnailImage(const std::string& filePath) const { + return thumbnailImages.count(filePath) > 0; + } + + void clear() { + fullImages.clear(); + thumbnailImages.clear(); + } + + private: + std::unordered_map> fullImages; + std::unordered_map> thumbnailImages; +}; + +} // namespace pagx diff --git a/pagx/wechat/src/PAGXView.cpp b/pagx/wechat/src/PAGXView.cpp new file mode 100644 index 0000000000..65482a9ebe --- /dev/null +++ b/pagx/wechat/src/PAGXView.cpp @@ -0,0 +1,1913 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#include "PAGXView.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "GridBackground.h" +#include "utils/StringParser.h" +#include "tgfx/core/Data.h" +#include "tgfx/core/Image.h" +#include "tgfx/core/ImageCodec.h" +#include "tgfx/core/Rect.h" +#include "tgfx/core/Stream.h" +#include "tgfx/core/Typeface.h" +#include "tgfx/platform/Print.h" +#include "pagx/nodes/Image.h" +#include "pagx/nodes/ImagePattern.h" +#include "pagx/PAGXImporter.h" +#include "pagx/types/Data.h" +#include "utils/ImagePatternMatrixCalculator.h" +#include "base/utils/Log.h" + +using namespace emscripten; + +namespace pagx { + +// RGBA bytes per pixel. +constexpr uint32_t RGBA_BYTES_PER_PIXEL = 4; +// Byte unit conversions. +constexpr size_t BYTES_PER_KB = 1024; +constexpr size_t BYTES_PER_MB = BYTES_PER_KB * BYTES_PER_KB; +// Mipmap overhead factor for GPU footprint estimation (4/3 ≈ 1.33x). +constexpr uint64_t MIPMAP_FACTOR_NUM = 4; +constexpr uint64_t MIPMAP_FACTOR_DEN = 3; +// GPU resource cache limit. Capped at 512MB on iOS WeChat: 1GB and 768MB both led to the +// mini-program being terminated under memory pressure (observed in production logs). +// 512MB causes more eviction churn during pan/zoom on image-heavy PAGX, but a brief +// retexture is preferable to losing the entire session. +constexpr size_t MAX_CACHE_LIMIT = 512U * BYTES_PER_MB; +// GPU resource expiration in frames. Matches the tgfx default; the byte-capacity path above +// remains the primary cap. +constexpr size_t EXPIRATION_FRAMES = 120; +// Slow frame threshold in milliseconds (more lenient than desktop 32ms due to WeChat environment). +constexpr double SLOW_FRAME_THRESHOLD_MS = 50.0; +// Recovery time window in milliseconds (longer than desktop 2s to reduce jitter). +constexpr double RECOVERY_WINDOW_MS = 3000.0; +// Timeout for detecting zoom-in gesture end (faster refinement). +constexpr double ZOOM_IN_END_TIMEOUT_MS = 300.0; +// Timeout for detecting zoom-out gesture end (slower refinement). +constexpr double ZOOM_OUT_END_TIMEOUT_MS = 800.0; +// Delay before retrying tile refinement upgrade when performance is still slow. +constexpr double UPGRADE_RETRY_DELAY_MS = 300.0; +// Minimum frames to confirm recovery in static state. +constexpr size_t MIN_RECOVERY_FRAMES_STATIC = 20; +// Minimum frames to confirm recovery after zoom ends. +constexpr size_t MIN_RECOVERY_FRAMES_ZOOM_END = 10; +// Log a breakdown whenever a single draw() call takes longer than this threshold. Adjust this +// constant to tune log verbosity. submit() is asynchronous in the WebGL backend, so its reported +// time only reflects CPU-side command submission, not actual GPU execution. +constexpr double SLOW_FRAME_LOG_THRESHOLD_MS = 200.0; +// Master switch for all draw()-path telemetry logs (slow-frame breakdown, first-frame breakdown). +// Hardcoded off in shipped builds to keep the WeChat log channel quiet; flip to true and rebuild +// when investigating a frame-pacing regression. `if constexpr` lets the compiler fully strip the +// log branches when disabled, so there is zero runtime cost in the default configuration. +constexpr bool DRAW_LOG_ENABLED = false; +// Wasm page size in bytes (64 KiB per page). +constexpr size_t WASM_PAGE_SIZE = 65536; +// Image size bucket thresholds in pixels. +constexpr uint64_t TINY_BUCKET_MAX = 10000ULL; +constexpr uint64_t SMALL_BUCKET_MAX = 100000ULL; +constexpr uint64_t MID_BUCKET_MAX = 500000ULL; +constexpr uint64_t LARGE_BUCKET_MAX = 1000000ULL; +constexpr uint64_t XL_BUCKET_MAX = 2000000ULL; +// Pixel threshold for [BigImg] log marker (1 MPx). +constexpr uint64_t BIG_IMG_THRESHOLD = 1000000ULL; +// Log throttling interval for image load/upgrade logs. +constexpr uint32_t IMAGE_LOG_THROTTLE = 32; +// Display list tile and subtree cache configuration. +constexpr size_t DEFAULT_MAX_TILE_COUNT = 256; +constexpr size_t DEFAULT_SUBTREE_CACHE_SIZE = 2048; +// Subtree cache size when zoomed out. +constexpr size_t ZOOMED_OUT_SUBTREE_CACHE_SIZE = 1024; +// Zoom drift margin to absorb float imprecision. +constexpr float ZOOM_DRIFT_MARGIN = 1.02f; +// Fit content scale threshold for high-resolution offscreen capture. +constexpr float HIGH_RES_FIT_SCALE_THRESHOLD = 0.15f; +// High-resolution pixel scale for offscreen capture. +constexpr float HIGH_RES_PIXEL_SCALE = 2.0f; +// Default pixel scale. +constexpr float DEFAULT_PIXEL_SCALE = 1.0f; +// Post-zoom-end delay before retrying tile refinement upgrade (ms). +constexpr double POST_ZOOM_UPGRADE_DELAY_MS = 200.0; +// Default ImagePattern scale factor (matches ImagePatternMatrixCalculator default). +constexpr float DEFAULT_IMAGE_SCALE_FACTOR = 0.5f; +// Tile refinement zoom divisor for zoomed-out content. +constexpr float TILE_REFINEMENT_ZOOM_DIVISOR = 0.33f; +// Max tile refinement count per frame. +constexpr int MAX_TILE_REFINEMENT = 3; +// CoCraft business-enum scale mode values. +constexpr int SCALE_MODE_FILL = 0; +constexpr int SCALE_MODE_FIT = 1; +constexpr int SCALE_MODE_STRETCH = 2; +constexpr int SCALE_MODE_TILE = 3; +// GPU probe log interval in frames. +constexpr int GPU_PROBE_INTERVAL = 60; + + + +// Wasm linear memory size in bytes (HEAP8.byteLength equivalent). Each wasm page is 64 KiB. +// Returns -1 on non-wasm platforms so the same probe can stay in cross-platform code. +static int64_t SampleWasmHeap() { +#if defined(__EMSCRIPTEN__) || defined(__wasm__) + return static_cast(__builtin_wasm_memory_size(0)) * WASM_PAGE_SIZE; +#else + return -1; +#endif +} + +// One-shot wasm heap log. Format kept simple so it can be grepped from the miniprogram console. +static void LogMemProbe(const char* tag) { + LOGI("[MemProbe] tag=%s wasmHeap=%lld", tag, + static_cast(SampleWasmHeap())); +} + +// Bucket index for an image's pixel area. Boundaries align with the [Img] log analysis: tiny +// icons cluster in bucket 0, thumbnail-grade photos in 1-2, full-resolution stuff in 3-5. +static int ImageSizeBucket(uint64_t pixels) { + if (pixels < TINY_BUCKET_MAX) return 0; + if (pixels < SMALL_BUCKET_MAX) return 1; + if (pixels < MID_BUCKET_MAX) return 2; + if (pixels < LARGE_BUCKET_MAX) return 3; + if (pixels < XL_BUCKET_MAX) return 4; + return 5; +} + +std::shared_ptr PAGXView::MakeFrom(int width, int height) { + if (width <= 0 || height <= 0) { + return nullptr; + } + + auto device = tgfx::GLDevice::Current(); + if (device == nullptr) { + return nullptr; + } + + return std::shared_ptr(new PAGXView(device, width, height)); +} + +// Reads the length of a JavaScript Uint8Array, allocates a matching uint8_t buffer with +// new(std::nothrow), and copies the array contents into it through a HEAPU8-backed view. +// Returns the freshly allocated buffer (caller owns it) and writes its length to outLength, or +// returns nullptr (with outLength = 0) when the input is undefined, empty, or the allocation +// fails. Shared by the tgfx::Data and pagx::Data converters below, which differ only in the +// final factory step. +static uint8_t* AllocAndCopyEmscriptenData(const val& emscriptenData, unsigned int* outLength) { + *outLength = 0; + if (emscriptenData.isUndefined()) { + return nullptr; + } + unsigned int length = emscriptenData["length"].as(); + if (length == 0) { + return nullptr; + } + auto buffer = new (std::nothrow) uint8_t[length]; + if (!buffer) { + return nullptr; + } + auto memory = val::module_property("HEAPU8")["buffer"]; + auto memoryView = emscriptenData["constructor"].new_( + memory, static_cast(reinterpret_cast(buffer)), length); + memoryView.call("set", emscriptenData); + *outLength = length; + return buffer; +} + +// Copies data from a JavaScript Uint8Array into a tgfx::Data object. +static std::shared_ptr GetDataFromEmscripten(const val& emscriptenData) { + unsigned int length = 0; + auto buffer = AllocAndCopyEmscriptenData(emscriptenData, &length); + if (!buffer) { + return nullptr; + } + return tgfx::Data::MakeAdopted(buffer, length, tgfx::Data::DeleteProc); +} + +// Copies data from a JavaScript Uint8Array into a pagx::Data object. +static std::shared_ptr GetPagxDataFromEmscripten(const val& emscriptenData) { + unsigned int length = 0; + auto buffer = AllocAndCopyEmscriptenData(emscriptenData, &length); + if (!buffer) { + return nullptr; + } + return Data::MakeAdopt(buffer, length); +} + +PAGXView::PAGXView(std::shared_ptr device, int width, int height) +: device(device), canvasWidth(width), canvasHeight(height) { + displayList.setRenderMode(tgfx::RenderMode::Tiled); + // Keep zoomScalePrecision at tgfx's default (1000, i.e. 0.001 step) so pinch gestures feel + // smooth. A previous experiment bucketed zoom to 0.05 steps to improve TileCache reuse, but + // that produced visible stair-stepping without fixing the underlying shape-rasterize cost. + displayList.setTileUpdateMode(tgfx::TileUpdateMode::Fast); + displayList.setMaxTileCount(DEFAULT_MAX_TILE_COUNT); + displayList.setMaxTilesRefinedPerFrame(currentMaxTilesRefinedPerFrame); + // Subtree cache: caches static layers as textures to avoid per-layer shape retriangulation, + // the dominant hotspot for PAGX content (Shape=200-400ms/frame on singleton shapes). + displayList.setSubtreeCacheMaxSize(DEFAULT_SUBTREE_CACHE_SIZE); +} + +PAGXView::~PAGXView() { + // Reclaim every backend texture this view still owns. The JS-side destroy() makes the GL + // context current before invoking nativeView.delete(), so calling destroyBackendTextures + // here goes to the right context. Walk both caches before clearing so the local + // pendingTextureDeletes list captures every id; clearing the maps afterwards drops the + // tgfx::Image shared_ptrs which in turn release the (non-adopted) TextureView from tgfx's + // ResourceCache. Anything already retired earlier this draw but not yet drained piggy-backs + // on the same destroyBackendTextures call. + for (auto& kv : externalTextures) { + retireTextureId(kv.second.textureId); + } + for (auto& kv : thumbnailTextures) { + retireTextureId(kv.second.textureId); + } + externalTextures.clear(); + thumbnailTextures.clear(); + drainPendingTextureDeletes(); +} + +void PAGXView::registerFonts(const val& fontVal, const val& emojiFontVal) { + std::vector> fallbackTypefaces; + auto fontData = GetDataFromEmscripten(fontVal); + if (fontData) { + auto typeface = tgfx::Typeface::MakeFromData(fontData, 0); + if (typeface) { + fallbackTypefaces.push_back(std::move(typeface)); + } + } + auto emojiFontData = GetDataFromEmscripten(emojiFontVal); + if (emojiFontData) { + auto typeface = tgfx::Typeface::MakeFromData(emojiFontData, 0); + if (typeface) { + fallbackTypefaces.push_back(std::move(typeface)); + } + } + fontConfig.addFallbackTypefaces(std::move(fallbackTypefaces)); +} + +void PAGXView::loadPAGX(const val& pagxData) { + parsePAGX(pagxData); + buildLayers(); +} + +void PAGXView::parsePAGX(const val& pagxData) { + LogMemProbe("parsePAGX.enter"); + // Release old resources early to reduce peak memory usage when switching pages. + displayList.root()->removeChildren(); + contentLayer = nullptr; + // Drop the per-document layer builder state before releasing the document itself; its + // internal maps hold pagx::Layer* pointers that would dangle once `document` resets. + builderSession = nullptr; + imageProvider = nullptr; + document = nullptr; + // Drop snapshots so the new document doesn't blit pixels from the old one. + fitSnapshot = nullptr; + fitSnapshotPixelScale = DEFAULT_PIXEL_SCALE; + gestureActive = false; + zoomedOutFrameSettled = false; + lastGestureEndMs = 0.0; + // Reset displayList view state. Without this, a render that happens between parsePAGX + // and buildLayers (the loader is async; the render loop keeps running) captures a fresh + // snapshot keyed against the *previous* document's zoom/offset. The next gesture + // would then composite that stale snapshot using the new layout's view state, giving + // the user the look-and-feel of the previous page. + displayList.setZoomScale(1.0f); + displayList.setContentOffset(0.0f, 0.0f); + lastZoom = 1.0f; + // Reset first-frame flag so JS-side loading flow can wait for the new document's first + // render via isFirstFrameRendered() instead of seeing a stale true from the previous one. + hasRenderedFirstFrame = false; + // Reset image-decode accounting for the new document. + imageDecodedPixelTotal = 0; + imageDecodedCount = 0; + for (auto& b : imageSizeBuckets) { + b = 0; + } + // Reset backend-texture caches: the previous document's entries hold both a tgfx::Image + // (released by the ExternalTextureEntry destructor below) and an externally-owned GL + // texture. We retire the GL texture ids here so the next draw's drainPendingTextureDeletes + // can free the GPU memory; the tgfx side has no lingering scratch-pool reference because + // these entries were created with Image::MakeFrom (non-adopted). Pending uploads from the + // previous document carry JS-side OffscreenCanvas references that should be released too — + // the corresponding pagx::Image nodes no longer exist, so any successful upload would have + // nowhere to land. + pendingUploads.clear(); + pendingThumbnailBytes = 0; + pendingFullBytes = 0; + for (auto& kv : externalTextures) { + retireTextureId(kv.second.textureId); + } + externalTextures.clear(); + for (auto& kv : thumbnailTextures) { + retireTextureId(kv.second.textureId); + } + thumbnailTextures.clear(); + externalTexturesTotalBytes = 0; + thumbnailTexturesTotalBytes = 0; + currentFrameIndex = 0; + // Drop any in-flight full requests carried over from the previous document; the new document + // has its own set of paths and the host will receive fresh onTextureRequest events on the + // first draw after buildLayers(). The handler registration is intentionally preserved so + // callers do not need to re-register on every parsePAGX(). + inFlightFullRequests.clear(); + evictedFullPaths.clear(); + imageOriginalSizes.clear(); + // Reset document-derived state up front so a failed parse below leaves clean defaults instead + // of retaining the previous document's dimensions / background. On success these are set again + // from the freshly parsed document right after FromXML. + pagxWidth = 0.0f; + pagxHeight = 0.0f; + backgroundVisible = false; + backgroundTGFXColor = DefaultBackgroundColor(); + + auto data = GetPagxDataFromEmscripten(pagxData); + if (!data) { + return; + } + size_t xmlSize = data->size(); + document = PAGXImporter::FromXML(data->bytes(), data->size()); + if (document) { + imageProvider = std::make_shared(); + document->setImageResourceProvider(imageProvider); + // Surface document dimensions and document-level custom data (background, bounds origin) + // immediately after parse so contentWidth()/contentHeight()/getContentTransform() are usable + // before buildLayers(). The progressive loader reads getContentTransform().fitScale right + // after parsePAGX() to decide first-frame image quality. buildLayers() no longer repeats it. + pagxWidth = document->width; + pagxHeight = document->height; + applyDocumentCustomData(); + auto paths = document->getExternalFilePaths(); + LOGI( + "[MemProbe] tag=parsePAGX.exit wasmHeap=%lld xmlBytes=%zu externalImageRefs=%zu", + static_cast(SampleWasmHeap()), xmlSize, paths.size()); + } else { + LogMemProbe("parsePAGX.exit.failed"); + } +} + +std::vector PAGXView::getExternalFilePaths() const { + if (!document) { + return {}; + } + return document->getExternalFilePaths(); +} + +bool PAGXView::loadFileData(const val& filePathVal, const val& fileData) { + auto filePath = filePathVal.as(); + if (!document) { + return false; + } + auto data = GetPagxDataFromEmscripten(fileData); + if (!data) { + return false; + } + return document->loadFileData(filePath, std::move(data)); +} + + + +bool PAGXView::attachNativeImage(const val& filePathVal, const val& nativeImage, + int qualityRaw) { + if (!document || !nativeImage.as()) { + return false; + } + + // filePath is taken as val and converted locally instead of using `const std::string&`: + // eviction below may grow wasm memory, which detaches JS-side HEAP views; an embind-managed + // string parameter would then be freed on a stale view in onDone -> runDestructors and trap. + // Do not add new memory.grow-prone work here while keeping any embind-managed heap parameter. + std::string filePath = filePathVal.as(); + if (filePath.empty()) { + return false; + } + + if (qualityRaw != static_cast(ImageQuality::Thumbnail) && + qualityRaw != static_cast(ImageQuality::Full)) { + return false; + } + auto quality = static_cast(qualityRaw); + + // Sample width/height once and stash them on the PendingUpload so neither the budget check + // below nor flushPendingUploads needs to bounce back through emscripten::val (each property + // access is a wasm↔JS round-trip, ~100x slower than a struct field read). + int width = nativeImage["width"].as(); + int height = nativeImage["height"].as(); + if (width < 1 || height < 1) { + return false; + } + uint64_t bytes = + static_cast(width) * static_cast(height) * static_cast(RGBA_BYTES_PER_PIXEL) + * MIPMAP_FACTOR_NUM / MIPMAP_FACTOR_DEN; + + // Thumbnail budget enforcement: estimate the upload's GPU footprint up front so we can run a + // silent LRU eviction before queuing. The estimate must match flushPendingUploads' bytes + // formula (RGBA8 + 1/3 mipmap multiplier) so the post-upload total stays consistent. If even + // after eviction the budget cannot fit, we drop the request rather than letting the cache + // grow unbounded; the caller will see false and can decide whether to skip or retry later. + if (quality == ImageQuality::Thumbnail) { + // Account for thumbnails that are already in pendingUploads but have not yet uploaded: + // flushPendingUploads commits all of them sequentially, so without this pendingUploads + // could bloat the realised total above thumbnailBudget. The running sum is kept in + // pendingThumbnailBytes so this stays O(1) regardless of queue length. + // + // Replacement subtraction mirrors the full branch below: when the same path already has a + // committed thumbnail entry, flushPendingUploads swaps it in place (-= old + += new), so + // the net delta is `bytes - oldEntry`, not `bytes`. Subtract the existing entry's sizeBytes + // here to avoid over-projecting and triggering an unnecessary eviction or drop. + uint64_t replacingBytes = 0; + auto existing = thumbnailTextures.find(filePath); + if (existing != thumbnailTextures.end()) { + replacingBytes = existing->second.sizeBytes; + } + uint64_t projected = + thumbnailTexturesTotalBytes + pendingThumbnailBytes + bytes - replacingBytes; + if (projected > thumbnailBudget) { + uint64_t over = projected - thumbnailBudget; + evictOldestThumbnailsUntilFits(over); + // Re-fetch after eviction: evictOldestThumbnailsUntilFits iterates in hash order and may + // have dropped this very path, in which case its bytes are already gone from + // thumbnailTexturesTotalBytes and must no longer be subtracted as a replacement. + replacingBytes = 0; + existing = thumbnailTextures.find(filePath); + if (existing != thumbnailTextures.end()) { + replacingBytes = existing->second.sizeBytes; + } + projected = + thumbnailTexturesTotalBytes + pendingThumbnailBytes + bytes - replacingBytes; + } + if (projected > thumbnailBudget) { + LOGI("[PAGX] thumbnailBudget exhausted, drop path=%s size=%dx%d", + filePath.c_str(), width, height); + return false; + } + } else { + // Full budget enforcement: same rationale as the thumbnail branch above, but eviction + // routes through enforceFullBudget() which honours the viewport-distance ranking and + // visible-path protection. Running this proactively here — instead of relying on the + // post-flush sweep inside flushPendingUploads — collapses the previous two-phase pattern + // (queue 8 fulls → flush all 8 → bulk-evict 17 in one shot) into a steady "evict 1, attach + // 1" rhythm that keeps externalTexturesTotalBytes hugging the budget line instead of + // overshooting and snapping back. The running sum pendingFullBytes mirrors + // pendingThumbnailBytes so the projection is O(1) regardless of queue length. + // + // Replacement subtraction: when the same path is already attached, flushPendingUploads + // performs an in-place swap (-= old + += new), so the net delta is `bytes - oldEntry`, + // not `bytes`. We subtract the existing entry's sizeBytes here to avoid over-projecting. + // SDK contract guarantees no Full→Full racing in practice (host only re-fetches after + // onTextureEvict), but the subtraction stays correct for the rare edge case. + uint64_t replacingBytes = 0; + auto existing = externalTextures.find(filePath); + if (existing != externalTextures.end()) { + replacingBytes = existing->second.sizeBytes; + } + uint64_t projected = + externalTexturesTotalBytes + pendingFullBytes + bytes - replacingBytes; + if (projected > fullBudget) { + // enforceFullBudget evicts off-viewport paths until totalBytes <= fullBudget. Running + // it before push_back means the upcoming upload sees an already-trimmed cache, so the + // post-flush enforceFullBudget call inside draw() typically becomes a no-op. We do not + // re-check after the eviction — even if the projected total still exceeds budget (every + // remaining entry sits inside the protected viewport), letting the queue accept the new + // upload is strictly better than silently dropping a host-driven attach: the post-flush + // sweep will log the overflow once per 60 frames and the host can react via + // isFullBudgetSaturated() on the next evaluate. + enforceFullBudget(); + } + } + + pendingUploads.push_back({filePath, nativeImage, quality, width, height, bytes}); + if (quality == ImageQuality::Thumbnail) { + pendingThumbnailBytes += bytes; + } else { + pendingFullBytes += bytes; + } + // Mark the request closed at queue time so a scan running before the next flush does not + // re-emit onTextureRequest for an asset the host has already handed back. If the eventual + // flush fails (system-level GL failure), the path will render from thumbnail indefinitely + // — accepted as a trade-off against an unbounded request → fail → re-request loop. + if (quality == ImageQuality::Full) { + inFlightFullRequests.erase(filePath); + evictedFullPaths.erase(filePath); + } + return true; +} + +uint64_t PAGXView::evictOldestThumbnailsUntilFits(uint64_t neededBytes) { + if (neededBytes == 0 || thumbnailTextures.empty() || !document) { + return 0; + } + // Thumbnails are intentionally evicted in hash-map iteration order rather than by recency + // or viewport distance: the cache is sized so this path is essentially unreachable in + // practice (~168 thumbnails fit, far more than any real document references), so any + // cycles spent on smarter ranking would be wasted. If hitting this path becomes routine + // the right fix is to grow thumbnailBudget, not to refine the policy. + // + // Collect the paths to evict first so we don't mutate the map while iterating it. Stop as + // soon as we have freed enough bytes, even if more entries remain. + std::vector toEvict; + uint64_t freed = 0; + for (const auto& [path, entry] : thumbnailTextures) { + if (freed >= neededBytes) { + break; + } + toEvict.push_back(path); + freed += entry.sizeBytes; + } + for (const auto& path : toEvict) { + auto it = thumbnailTextures.find(path); + if (it == thumbnailTextures.end()) { + continue; + } + thumbnailTexturesTotalBytes -= it->second.sizeBytes; + retireTextureId(it->second.textureId); + thumbnailTextures.erase(it); + // Remove the thumbnail from the provider and rebuild affected layers so a subsequent draw + // does not keep referencing a tgfx::Image that no longer has a cache entry pinning its + // lifetime. + imageProvider->clearThumbnailImage(path); + if (builderSession) { + builderSession->rebuildForFilePath(path); + } + } + return freed; +} + +void PAGXView::flushPendingUploads(tgfx::Context* context) { + if (pendingUploads.empty() || context == nullptr || !document) { + return; + } + // Snapshot then clear so the JS-side OffscreenCanvas references held inside each PendingUpload + // are released as soon as the upload completes; if anything throws or we early-return the + // queue is still emptied for the next frame. + std::vector uploads; + uploads.swap(pendingUploads); + // pendingThumbnailBytes mirrored pendingUploads — once we swap the queue out, the running + // sum belongs to the local `uploads` vector and the in-place sum resets to zero. Subsequent + // attachNativeImage(Thumbnail) calls during the upload (defensive: shouldn't happen since + // we're on the wasm thread) will start from a clean tally. + pendingThumbnailBytes = 0; + pendingFullBytes = 0; + + for (auto& p : uploads) { + // Hand the JS-side native image to the TS helper which creates a GL texture, registers it + // with emscripten's GL.textures table, uploads pixels via texImage2D, and runs + // generateMipmap. Returns the GL.textures index (>0 on success) so the wasm side can pass + // the same id to tgfx through GLTextureInfo. + int textureId = val::module_property("tgfx").call( + "createBackendTexture", val::module_property("GL"), p.nativeImage); + if (textureId <= 0) { + LOGI("[PAGX] attachNativeImage: createBackendTexture failed path=%s", + p.filePath.c_str()); + continue; + } + + tgfx::GLTextureInfo glInfo = {}; + glInfo.id = static_cast(textureId); + tgfx::BackendTexture backendTex(glInfo, p.width, p.height); + // Use MakeFrom (non-adopted) rather than MakeAdopted: tgfx wraps the GL texture without + // claiming ownership, so when our shared_ptr drops the underlying TextureView is + // removed from the ResourceCache immediately instead of being parked in the scratch pool + // to wait for an LRU sweep. We retain the textureId on the entry and call gl.deleteTexture + // ourselves at the end of draw() (drainPendingTextureDeletes), guaranteeing the GPU memory + // is released in the same frame the entry is dropped. + auto tgfxImage = tgfx::Image::MakeFrom(context, backendTex, tgfx::ImageOrigin::TopLeft); + if (!tgfxImage) { + LOGI("[PAGX] attachNativeImage: MakeFrom failed path=%s", p.filePath.c_str()); + // The GL texture was created successfully but tgfx refused to wrap it. Reclaim the + // texture immediately so we don't leak the GPU memory; queueing into + // pendingTextureDeletes keeps the actual gl.deleteTexture call inside the same + // drainPendingTextureDeletes() pass that handles eviction-side retirements. + retireTextureId(static_cast(textureId)); + continue; + } + + if (p.quality == ImageQuality::Full) { + // Store the tgfx::Image in the provider so the renderer picks it up via + // resolveImage() on the next getOrCreateImage call after cache invalidation. + imageProvider->putFullImage(p.filePath, tgfxImage); + // Replace the previous full entry. The old entry's tgfxImage drops here, immediately + // freeing tgfx's TextureView; retire the previous textureId so its GPU memory is + // reclaimed at the end of this same draw(). + auto& entry = externalTextures[p.filePath]; + if (entry.image) { + externalTexturesTotalBytes -= entry.sizeBytes; + retireTextureId(entry.textureId); + } + entry.image = tgfxImage; + entry.sizeBytes = p.sizeBytes; + entry.textureId = static_cast(textureId); + externalTexturesTotalBytes += p.sizeBytes; + // Refresh ImagePattern matrices against the newly attached image dimensions so + // progressive thumbnail->full swaps stay aligned. + ResolveImagePatternMatricesByFilePath(document.get(), p.filePath, &imageOriginalSizes, + imageProvider.get()); + if (builderSession) { + builderSession->rebuildForFilePath(p.filePath); + } + } else { + // Thumbnail: store in the provider's thumbnail slot. Budget enforcement and silent LRU + // eviction happen up front in attachNativeImage() so flushPendingUploads only sees + // uploads that already fit. + imageProvider->putThumbnailImage(p.filePath, tgfxImage); + auto& entry = thumbnailTextures[p.filePath]; + if (entry.image) { + thumbnailTexturesTotalBytes -= entry.sizeBytes; + retireTextureId(entry.textureId); + } + entry.image = tgfxImage; + entry.sizeBytes = p.sizeBytes; + entry.textureId = static_cast(textureId); + thumbnailTexturesTotalBytes += p.sizeBytes; + // Refresh ImagePattern matrices against the freshly attached thumbnail's pixel + // dimensions. Without this, fills that have never seen a Full attach yet would keep + // the matrix baked at parsePAGX time (orig-image-* dimensions), which scales the UV + // mapping for a thumbnail-sized texture by the original full-resolution divisor — + // sampling lands outside the thumbnail's [0,1] domain and the fill renders blank + // (clamped to edge). ResolveImagePatternMatrix's source priority falls through to + // the provider's thumbnail when full is absent so this stays correct after a later + // Full attach overwrites the matrix to the full-resolution dimensions. + ResolveImagePatternMatricesByFilePath(document.get(), p.filePath, &imageOriginalSizes, + imageProvider.get()); + // Regenerate the affected layers' vector contents so the newly attached thumbnail is + // picked up by the cached layer tree. + if (builderSession) { + builderSession->rebuildForFilePath(p.filePath); + } + } + } +} + +void PAGXView::enforceFullBudget() { + if (!document || externalTexturesTotalBytes <= fullBudget || externalTextures.empty()) { + return; + } + // Bounds collection runs only on the over-budget path. We pay this cost once per draw that + // actually exceeds budget, never on the steady-state happy path. + tgfx::Rect viewport = computeViewportInRootCoords(); + auto pathBounds = computeFullPathBounds(); + // Visibility protection uses the (1.2x-expanded) viewport returned by + // computeViewportInRootCoords; the same rect's center is the focal point used to rank + // off-viewport candidates. Both reads of `viewport` below operate on the same struct so the + // two notions of "near" stay consistent. + float focusX = viewport.centerX(); + float focusY = viewport.centerY(); + size_t visibleCount = 0; + + // Candidate := off-viewport entry that may be evicted. Visible paths are dropped on the + // floor so they never compete for eviction. Distance-squared (no sqrt) is enough for + // sorting since sqrt is monotonic. + struct Candidate { + float distSq; + uint64_t sizeBytes; + std::string path; + // Orders candidates farthest-first so eviction starts with the entries furthest from focus. + static bool FartherFirst(const Candidate& a, const Candidate& b) { + return a.distSq > b.distSq; + } + }; + std::vector candidates; + candidates.reserve(externalTextures.size()); + for (const auto& [path, entry] : externalTextures) { + auto it = pathBounds.find(path); + if (it == pathBounds.end()) { + // Path has no resolvable layer bounds (degenerate / not yet attached to layer tree). + // Treat as "infinitely far" so it always loses to bounded candidates, matching the + // intuition that an entry no layer references is the cheapest to drop. + candidates.push_back({std::numeric_limits::infinity(), entry.sizeBytes, path}); + continue; + } + const auto& bounds = it->second; + if (tgfx::Rect::Intersects(bounds, viewport)) { + ++visibleCount; + continue; + } + float dx = bounds.centerX() - focusX; + float dy = bounds.centerY() - focusY; + candidates.push_back({dx * dx + dy * dy, entry.sizeBytes, path}); + } + // Farthest first. Modeled on tgfx's tiled rasterization which prioritizes tiles closest to + // the user's focal point — we keep the same focal-distance gradient but invert the sweep + // direction (closest stays, farthest goes). + std::sort(candidates.begin(), candidates.end(), &Candidate::FartherFirst); + + std::vector evictedPaths; + for (const auto& c : candidates) { + if (externalTexturesTotalBytes <= fullBudget) { + break; + } + auto it = externalTextures.find(c.path); + if (it == externalTextures.end()) { + continue; + } + externalTexturesTotalBytes -= it->second.sizeBytes; + // Reclaim the GL texture this draw, before flush+submit publishes the new RenderTask + // graph. drainPendingTextureDeletes() runs after submit so any in-flight render that + // sampled the texture has already been recorded into a command buffer; WebGL guarantees + // those draws keep working until the buffer drains. + retireTextureId(it->second.textureId); + externalTextures.erase(it); + // Remove the full-quality image from the provider so the renderer falls back to thumbnail + // on the next rebuild. scanAndRequestMissingTextures() will pick the dropped paths up on + // the same frame and emit onTextureRequest, so the host can start fetching a replacement + // immediately. + imageProvider->clearFullImage(c.path); + // The render loop will fall back to thumbnail on the next draw. Recompute the + // ImagePattern matrix so it matches the thumbnail's pixel dimensions instead of the + // full-resolution dimensions that were baked when the now-evicted full image was + // attached; otherwise the fallback fill would sample outside the thumbnail's UV domain + // and render blank (clamp-to-edge). When a replacement Full is later re-attached, the + // upload path runs the same resolve again with the full-resolution dimensions. + ResolveImagePatternMatricesByFilePath(document.get(), c.path, &imageOriginalSizes, + imageProvider.get()); + if (builderSession) { + builderSession->rebuildForFilePath(c.path); + } + evictedPaths.push_back(c.path); + // Mark the path as a candidate for the next scanAndRequestMissingTextures sweep so the + // host receives onTextureRequest the same draw and can start re-fetching immediately. + // attachNativeImage(Full) will erase the entry once a replacement upload is queued. + evictedFullPaths.insert(c.path); + } + // Still over budget after the sweep means every remaining entry sits inside the protected + // viewport. Letting the budget overflow temporarily is strictly better than evicting a + // visible texture (which would fall back to thumbnail and immediately trigger a re-download + // under handleTextureRequest, oscillating between full and thumbnail at the user's focal + // point). Throttle the warning so a sustained overflow does not flood the log. + if (externalTexturesTotalBytes > fullBudget && + currentFrameIndex - lastFullBudgetOverflowWarnFrame >= GPU_PROBE_INTERVAL) { + lastFullBudgetOverflowWarnFrame = currentFrameIndex; + LOGI( + "[PAGX] fullBudget exceeded by %lluMB; %zu visible paths protected from eviction", + static_cast((externalTexturesTotalBytes - fullBudget) / BYTES_PER_MB), + visibleCount); + } + // Notify the host once per draw with the full batch so JS only takes a single trip across + // the boundary regardless of how many paths were dropped. + emitTextureEvict(evictedPaths); +} + +tgfx::Rect PAGXView::computeViewportInRootCoords() const { + if (canvasWidth <= 0 || canvasHeight <= 0) { + return tgfx::Rect::MakeEmpty(); + } + // Layer hierarchy: displayList.root() applies (zoomScale, contentOffset); contentLayer below + // it carries the (fitScale, centerOffset) matrix from applyCenteringTransform(). Layer bounds + // returned by getBounds(rootLayer = displayList.root()) live in the root frame, AFTER + // contentLayer's matrix is folded in but BEFORE displayList's zoomScale/contentOffset are + // applied. So we only need to invert the displayList transform here: + // canvas_pixel = root_coord * zoomScale + contentOffset + // root_coord = (canvas_pixel - contentOffset) / zoomScale + float zoomScale = static_cast(displayList.zoomScale()); + if (zoomScale <= 0.0f) { + return tgfx::Rect::MakeEmpty(); + } + auto offset = displayList.contentOffset(); + float left = (-offset.x) / zoomScale; + float top = (-offset.y) / zoomScale; + float width = static_cast(canvasWidth) / zoomScale; + float height = static_cast(canvasHeight) / zoomScale; + // 1.2x expansion around the viewport center absorbs fling-deceleration overshoot and small + // user adjustments that would otherwise momentarily push an in-focus texture outside the + // strict viewport, triggering an avoidable evict→re-upload cycle. Match the same factor + // anywhere else that intersects this rect so the protection envelope stays consistent. + constexpr float kViewportExpansion = 1.2f; + float expandX = width * (kViewportExpansion - 1.0f) * 0.5f; + float expandY = height * (kViewportExpansion - 1.0f) * 0.5f; + return tgfx::Rect::MakeLTRB(left, top, left + width, top + height) + .makeOutset(expandX, expandY); +} + +std::unordered_map PAGXView::computeFullPathBounds() const { + std::unordered_map result; + if (!document || !builderSession || !contentLayer) { + return result; + } + auto* rootLayer = contentLayer->root(); + if (!rootLayer) { + return result; + } + result.reserve(externalTextures.size()); + // Only inspect paths actually present in the cache — they are the only candidates the + // eviction sweep needs to reason about. Iterating every image node in the document would + // waste cycles on paths that have no full-quality entry to begin with. Mirrors the shape + // of getImageBounds() for consistency. + for (const auto& kv : externalTextures) { + const auto& path = kv.first; + auto tgfxLayers = builderSession->getTgfxLayersByImageFilePath(path); + if (tgfxLayers.empty()) { + continue; + } + tgfx::Rect unionBounds = tgfx::Rect::MakeEmpty(); + bool hasAny = false; + for (const auto& tgfxLayer : tgfxLayers) { + if (!tgfxLayer) { + continue; + } + // tgfx caches bounds after the first getBounds() call, so repeated lookups across + // frames are O(1) — only the first eviction sweep on a freshly built layer tree + // pays the lazy compute cost. + auto bounds = tgfxLayer->getBounds(rootLayer); + if (bounds.isEmpty()) { + continue; + } + if (!hasAny) { + unionBounds = bounds; + hasAny = true; + } else { + unionBounds.join(bounds); + } + } + if (hasAny) { + result.emplace(path, unionBounds); + } + } + return result; +} + +void PAGXView::setTextureEventHandler(const val& handler) { + // Treat both undefined and null as "clear handler"; everything else is recorded as-is. The + // val is held by value so the JS-side object stays reachable for the lifetime of this view. + if (!handler.as()) { + textureEventHandler = val::undefined(); + return; + } + textureEventHandler = handler; +} + +bool PAGXView::isFullBudgetSaturated() const { + return externalTexturesTotalBytes >= fullBudgetHardCap; +} + +void PAGXView::retireTextureId(unsigned textureId) { + // 0 means "no GL id was associated", which legitimately happens for default-constructed + // entries that never completed an upload. Skipping zero keeps callers from having to guard + // every single retire site, and prevents handing 0 to gl.deleteTexture (a no-op in spec + // but spammy in driver logs). + if (textureId == 0) { + return; + } + pendingTextureDeletes.push_back(textureId); +} + +void PAGXView::drainPendingTextureDeletes() { + if (pendingTextureDeletes.empty()) { + return; + } + // Hand the whole batch to JS in one call so the wasm↔JS boundary is crossed once instead of + // once per id. The JS helper iterates the array internally and removes each id from + // GL.textures alongside the gl.deleteTexture invocation. Any retire that snuck in after the + // helper started running stays in the queue until the next drain — we swap a fresh empty + // vector in to make that explicit. + std::vector toDelete; + toDelete.swap(pendingTextureDeletes); + val arr = val::array(); + for (size_t i = 0; i < toDelete.size(); ++i) { + arr.set(i, toDelete[i]); + } + val::module_property("tgfx").call("destroyBackendTextures", val::module_property("GL"), + arr); +} + +void PAGXView::emitTextureRequest(const std::string& filePath) { + if (!textureEventHandler.as() || filePath.empty()) { + return; + } + // hasOwnProperty would not see methods on the prototype chain; "in" mirrors what JS code + // would do with `if ('onTextureRequest' in handler)`. Missing methods are silently ignored + // so callers can register a handler with only the events they care about. + if (!textureEventHandler["onTextureRequest"].as()) { + return; + } + textureEventHandler.call("onTextureRequest", filePath); +} + +void PAGXView::emitTextureEvict(const std::vector& filePaths) { + if (filePaths.empty() || !textureEventHandler.as()) { + return; + } + if (!textureEventHandler["onTextureEvict"].as()) { + return; + } + // Build a JS Array out of the C++ vector so the host receives a native array rather than a + // wasm-side opaque handle. + val arr = val::array(); + for (size_t i = 0; i < filePaths.size(); ++i) { + arr.set(i, filePaths[i]); + } + textureEventHandler.call("onTextureEvict", arr); +} + +void PAGXView::scanAndRequestMissingTextures() { + if (!document || !textureEventHandler.as() || evictedFullPaths.empty()) { + return; + } + // We deliberately scan only paths that have been evicted by a previous LRU sweep. The + // initial-attach window — where the provider has no full image because the host has not yet + // downloaded and called attachNativeImage(Full) for the first time — is owned by the host's + // own progressive image flow, not by us. Emitting onTextureRequest there would force the + // host into an "always fetch full" mode and defeat thumbnail-first loading. + // + // Snapshot into a vector so the inFlight bookkeeping below can mutate evictedFullPaths + // without invalidating an in-progress iterator. + std::vector candidates(evictedFullPaths.begin(), evictedFullPaths.end()); + for (const auto& filePath : candidates) { + // Defensive: skip paths whose full image somehow re-arrived between eviction and this + // scan (concurrent attach in another emit path, or future code paths). evictedFullPaths + // gets erased on attach, so this is mostly belt-and-braces. + if (filePath.empty()) { + continue; + } + if (inFlightFullRequests.count(filePath) > 0) { + continue; + } + inFlightFullRequests.insert(filePath); + emitTextureRequest(filePath); + } +} + +namespace { + +val RectToJSObject(const tgfx::Rect& rect) { + val obj = val::object(); + obj.set("x", rect.left); + obj.set("y", rect.top); + obj.set("w", rect.width()); + obj.set("h", rect.height()); + return obj; +} + +} // namespace + +val PAGXView::getImageBounds(const val& filePathList) const { + val result = val::object(); + if (!document || !builderSession || !contentLayer) { + return result; + } + auto* rootLayer = contentLayer->root(); + if (!rootLayer) { + return result; + } + + auto count = filePathList["length"].as(); + for (unsigned i = 0; i < count; ++i) { + std::string filePath = filePathList[i].as(); + auto tgfxLayers = builderSession->getTgfxLayersByImageFilePath(filePath); + if (tgfxLayers.empty()) { + result.set(filePath, val::null()); + continue; + } + tgfx::Rect unionBounds = tgfx::Rect::MakeEmpty(); + tgfx::Rect largestBounds = tgfx::Rect::MakeEmpty(); + float largestArea = 0.0f; + bool hasAny = false; + for (const auto& tgfxLayer : tgfxLayers) { + if (!tgfxLayer) { + continue; + } + // First getBounds(rootLayer) for a given tgfx Layer triggers tgfx's lazy localBounds + // evaluation (walks down to content, multiplies up ancestor matrices); later calls + // hit the cached value. + auto bounds = tgfxLayer->getBounds(rootLayer); + if (bounds.isEmpty()) { + continue; + } + if (!hasAny) { + unionBounds = bounds; + largestBounds = bounds; + largestArea = bounds.width() * bounds.height(); + hasAny = true; + } else { + unionBounds.join(bounds); + float area = bounds.width() * bounds.height(); + if (area > largestArea) { + largestArea = area; + largestBounds = bounds; + } + } + } + if (hasAny) { + val entry = val::object(); + entry.set("unionBounds", RectToJSObject(unionBounds)); + entry.set("largestBounds", RectToJSObject(largestBounds)); + result.set(filePath, entry); + } else { + result.set(filePath, val::null()); + } + } + return result; +} + +void PAGXView::setImageOriginalSize(const val& filePathVal, float width, float height) { + auto filePath = filePathVal.as(); + if (filePath.empty() || width <= 0.0f || height <= 0.0f) { + return; + } + imageOriginalSizes[filePath] = {width, height}; +} + +// Parses a float-valued customData attribute, returning `fallback` when the key is absent or +// the value is not numeric. +static float ParseFloatAttr(const std::unordered_map& data, + const char* key, float fallback) { + auto it = data.find(key); + if (it == data.end()) { + return fallback; + } + char* end = nullptr; + float value = std::strtof(it->second.c_str(), &end); + if (end == it->second.c_str()) { + return fallback; + } + return value; +} + +// Parses an int-valued customData attribute, returning `fallback` when the key is absent or +// the value is not numeric. +static int ParseIntAttr(const std::unordered_map& data, const char* key, + int fallback) { + auto it = data.find(key); + if (it == data.end()) { + return fallback; + } + char* end = nullptr; + long value = std::strtol(it->second.c_str(), &end, 10); + if (end == it->second.c_str()) { + return fallback; + } + return static_cast(value); +} + +val PAGXView::getImageMetadata() const { + val result = val::array(); + if (!document) { + return result; + } + + // Aggregate ImagePattern usages by the filePath of their referenced Image node so a single + // file shared across multiple fills only produces one entry. We keep the insertion order + // stable so later JS-side logs stay readable; switching to unordered_map would shuffle the + // entries across runs. Data URIs and inline data-backed images never surface to the JS + // downloader, so we skip them here as well. + std::vector orderedPaths; + std::unordered_map pathIndex; + std::unordered_map> originalDimensions; + std::unordered_map> usagesByPath; + + for (const auto& node : document->nodes) { + if (!node || node->nodeType() != NodeType::ImagePattern) { + continue; + } + const auto* pattern = static_cast(node.get()); + if (!pattern->image || pattern->image->filePath.empty()) { + continue; + } + const std::string& filePath = pattern->image->filePath; + // Skip data URIs: the JS downloader never touches them so surfacing them would just + // clutter the output. + if (filePath.compare(0, 5, "data:") == 0) { + continue; + } + + const auto& data = pattern->customData; + float origWidth = ParseFloatAttr(data, "orig-image-width", 0.0f); + float origHeight = ParseFloatAttr(data, "orig-image-height", 0.0f); + float nodeWidth = ParseFloatAttr(data, "node-width", 0.0f); + float nodeHeight = ParseFloatAttr(data, "node-height", 0.0f); + // scaleMode mapping: the JS side expects the CoCraft business enum + // (0=FILL, 1=FIT, 2=STRETCH, 3=TILE), not the pagx::ScaleMode enum. Two PAGX exporter + // generations need to be handled here: + // - Legacy (CoCraft pre-rework): writes "image-scale-mode" into customData with the + // business-enum integer directly. The XML side does not carry a "scaleMode" attribute, + // so PAGXImporter falls back to the default LetterBox and pattern->scaleMode is not a + // reliable source. We honour the customData value as-is. + // - New (CoCraft post-rework): omits "image-scale-mode" and instead writes the canonical + // "scaleMode" XML attribute plus tileModeX="repeat" for tiled fills. PAGXImporter + // deserialises both onto the pattern, so we recover the business-enum integer by + // inspecting the structured fields. The mapping is unambiguous because TILE is the + // only mode that uses tileModeX=Repeat. + int scaleMode = 0; + if (data.find("image-scale-mode") != data.end()) { + scaleMode = ParseIntAttr(data, "image-scale-mode", 0); + } else if (pattern->tileModeX == TileMode::Repeat) { + scaleMode = SCALE_MODE_TILE; + } else if (pattern->scaleMode == ScaleMode::Zoom) { + scaleMode = SCALE_MODE_FILL; + } else if (pattern->scaleMode == ScaleMode::LetterBox) { + scaleMode = SCALE_MODE_FIT; + } else { + // Stretch / None both fall through here. None is only emitted by SVGImporter for + // absolute-coordinate fills which the WeChat host never feeds through this metadata + // path, so treating it as STRETCH is a safe fallback for the edge case. + scaleMode = SCALE_MODE_STRETCH; + } + // DEFAULT_IMAGE_SCALE_FACTOR matches the default used by ImagePatternMatrixCalculator so TILE + // patterns that omit the attribute behave identically in C++ and JS. + float scaleFactor = ParseFloatAttr(data, "scale-factor", DEFAULT_IMAGE_SCALE_FACTOR); + + if (pathIndex.find(filePath) == pathIndex.end()) { + pathIndex[filePath] = static_cast(orderedPaths.size()); + orderedPaths.push_back(filePath); + originalDimensions[filePath] = {origWidth, origHeight}; + usagesByPath[filePath] = {}; + } else { + // Keep the first positive dimension we saw: different ImagePatterns pointing at the + // same Image node may store zero for one or both dimensions depending on export path. + auto& existing = originalDimensions[filePath]; + if (existing.first <= 0.0f && origWidth > 0.0f) existing.first = origWidth; + if (existing.second <= 0.0f && origHeight > 0.0f) existing.second = origHeight; + } + + val usage = val::object(); + usage.set("nodeWidth", nodeWidth); + usage.set("nodeHeight", nodeHeight); + usage.set("scaleMode", scaleMode); + usage.set("scaleFactor", scaleFactor); + usagesByPath[filePath].push_back(std::move(usage)); + } + + for (size_t i = 0; i < orderedPaths.size(); ++i) { + const auto& filePath = orderedPaths[i]; + val entry = val::object(); + entry.set("filePath", filePath); + entry.set("origWidth", originalDimensions[filePath].first); + entry.set("origHeight", originalDimensions[filePath].second); + val usageArr = val::array(); + const auto& usages = usagesByPath[filePath]; + for (size_t j = 0; j < usages.size(); ++j) { + usageArr.set(static_cast(j), usages[j]); + } + entry.set("usages", usageArr); + result.set(static_cast(i), entry); + } + return result; +} + +void PAGXView::buildLayers() { + if (!document) { + return; + } + LOGI( + "[MemProbe] tag=buildLayers.enter wasmHeap=%lld imageCount=%u pixels=%llu (~%lluMB RGBA)", + static_cast(SampleWasmHeap()), imageDecodedCount, + static_cast(imageDecodedPixelTotal), + static_cast(imageDecodedPixelTotal * RGBA_BYTES_PER_PIXEL / BYTES_PER_MB)); + LOGI( + "[ImgHist] tiny<10K=%u small<100K=%u mid<500K=%u large<1M=%u xl<2M=%u huge>=2M=%u", + imageSizeBuckets[0], imageSizeBuckets[1], imageSizeBuckets[2], + imageSizeBuckets[3], imageSizeBuckets[4], imageSizeBuckets[5]); + + // PAGX files exported by CoCraft reference images by hash, so actual pixel dimensions are + // unavailable at export time. The exporter stores a normalized transform and scale parameters in + // customData instead. Now that images are downloaded, combine them with actual pixel dimensions + // to compute the final ImagePattern matrices. PAGX files generated by libpag embed image data + // directly and do not need this step. + // TODO: Integrate this into LayerBuilder so all PAGX renderers get it automatically. + ResolveAllImagePatternMatrices(document.get(), &imageOriginalSizes, imageProvider.get()); + + document->applyLayout(&fontConfig); + LogMemProbe("buildLayers.afterApplyLayout"); + + // Route through LayerBuilderSession so the build state survives into the progressive image + // upgrade path; post-build attachNativeImage(Full) calls rebuildForFilePath() on this session. + builderSession = std::make_unique(); + auto buildResult = builderSession->build(document.get()); + contentLayer = buildResult.root; + + if (!contentLayer) { + LogMemProbe("buildLayers.exit.noContent"); + return; + } + hasRenderedFirstFrame = false; + displayList.root()->addChild(contentLayer); + applyCenteringTransform(); + LogMemProbe("buildLayers.exit"); +} + +void PAGXView::applyDocumentCustomData() { + backgroundVisible = false; + backgroundTGFXColor = DefaultBackgroundColor(); + auto& customData = document->customData; + auto visibleIt = customData.find("bg-visible"); + if (visibleIt != customData.end()) { + backgroundVisible = (visibleIt->second == "true"); + } + if (backgroundVisible) { + auto colorIt = customData.find("bg-color"); + if (colorIt != customData.end()) { + backgroundTGFXColor = ParseHexColor(colorIt->second); + } + auto alphaIt = customData.find("bg-alpha"); + if (alphaIt != customData.end()) { + char* end = nullptr; + float alpha = std::strtof(alphaIt->second.c_str(), &end); + if (end != alphaIt->second.c_str()) { + backgroundTGFXColor.alpha *= alpha; + } + } + } + if (!boundsOriginOverridden) { + auto originXIt = customData.find("bounds-origin-x"); + if (originXIt != customData.end()) { + char* end = nullptr; + float value = std::strtof(originXIt->second.c_str(), &end); + if (end != originXIt->second.c_str()) { + boundsOriginX = value; + } + } + auto originYIt = customData.find("bounds-origin-y"); + if (originYIt != customData.end()) { + char* end = nullptr; + float value = std::strtof(originYIt->second.c_str(), &end); + if (end != originYIt->second.c_str()) { + boundsOriginY = value; + } + } + } +} + +void PAGXView::updateSize(int width, int height) { + if (width <= 0 || height <= 0) { + return; + } + + canvasWidth = width; + canvasHeight = height; + surface = nullptr; + // Snapshots are bound to the old surface size; drop them to avoid dimension mismatch. + fitSnapshot = nullptr; + fitSnapshotPixelScale = DEFAULT_PIXEL_SCALE; + gestureActive = false; + zoomedOutFrameSettled = false; + lastGestureEndMs = 0.0; + + if (contentLayer) { + applyCenteringTransform(); + } +} + +// Computes the contain-mode fit scale for the PAGX content against the canvas. Used by both +// applyCenteringTransform() (content matrix) and getContentTransform() (comment-pin +// coordinates) so the two stay in sync; any divergence here produces pins that drift relative +// to the content. +// +// No lower bound is applied: when the design is larger than the canvas we scale it down to fit +// so the user sees the whole document on first frame. This is mandatory for the progressive +// image loading flow because a 1:1 clamp would skip rasterization of any tile outside the +// canvas and therefore any image outside the top-left region, which in turn starves the +// viewport-based upgrade scoring in ProgressiveImageUpgrader. The increased fill cost is +// offset by downloading 20p thumbnails first, so the first-frame texture payload stays low. +// Returns 0 when dimensions are invalid; callers must check before using the result. +float PAGXView::computeFitScale() const { + if (canvasWidth <= 0 || canvasHeight <= 0 || pagxWidth <= 0 || pagxHeight <= 0) { + return 0.0f; + } + float scaleX = static_cast(canvasWidth) / pagxWidth; + float scaleY = static_cast(canvasHeight) / pagxHeight; + return std::min(scaleX, scaleY); +} + +void PAGXView::applyCenteringTransform() { + if (!contentLayer) { + return; + } + float scale = computeFitScale(); + if (scale <= 0.0f) { + return; + } + float offsetX = (static_cast(canvasWidth) - pagxWidth * scale) * 0.5f; + float offsetY = (static_cast(canvasHeight) - pagxHeight * scale) * 0.5f; + + auto matrix = tgfx::Matrix::MakeTrans(offsetX, offsetY); + matrix.preScale(scale, scale); + + contentLayer->setMatrix(matrix); +} + +void PAGXView::setBoundsOrigin(float x, float y) { + boundsOriginX = x; + boundsOriginY = y; + boundsOriginOverridden = true; +} + +void PAGXView::setGestureActive(bool active) { + if (active) { + // At this stage only fitSnapshot is used for compositing; cached is no longer captured. + if (fitSnapshot == nullptr) { + gestureActive = false; + return; + } + } else { + lastGestureEndMs = emscripten_get_now(); + } + zoomedOutFrameSettled = false; + gestureActive = active; +} + +void PAGXView::setSnapshotEnabled(bool enabled) { + if (snapshotEnabled == enabled) { + return; + } + snapshotEnabled = enabled; + if (!enabled) { + // Drop the captured snapshot so a stale image cannot resurface if the switch is later + // re-enabled mid-session. Also clear the zoom-out idle token so the next draw() runs a full + // render instead of short-circuiting on a now-disabled path. gestureActive stays as-is + // (the caller's gesture lifecycle is independent), but with no snapshot the fast path + // in draw() naturally falls through to the full-render branch. + fitSnapshot = nullptr; + fitSnapshotPixelScale = DEFAULT_PIXEL_SCALE; + zoomedOutFrameSettled = false; + } +} + +val PAGXView::getContentTransform() const { + float fitScale = computeFitScale(); + float centerOffsetX = 0.0f; + float centerOffsetY = 0.0f; + if (fitScale > 0.0f) { + centerOffsetX = (static_cast(canvasWidth) - pagxWidth * fitScale) * 0.5f; + centerOffsetY = (static_cast(canvasHeight) - pagxHeight * fitScale) * 0.5f; + } + val result = val::object(); + result.set("boundsOriginX", boundsOriginX); + result.set("boundsOriginY", boundsOriginY); + result.set("fitScale", fitScale); + result.set("centerOffsetX", centerOffsetX); + result.set("centerOffsetY", centerOffsetY); + return result; +} + +val PAGXView::getNodePosition(const std::string& nodeId) const { + val result = val::object(); + result.set("found", false); + result.set("x", 0.0f); + result.set("y", 0.0f); + + if (!document || nodeId.empty()) { + return result; + } + + auto* node = document->findNode(nodeId); + if (!node) { + return result; + } + + auto it = node->customData.find("page-offset"); + if (it == node->customData.end()) { + return result; + } + + const std::string& value = it->second; + auto commaPos = value.find(','); + if (commaPos == std::string::npos) { + return result; + } + + char* endX = nullptr; + char* endY = nullptr; + float x = std::strtof(value.c_str(), &endX); + float y = std::strtof(value.c_str() + commaPos + 1, &endY); + + if (endX != value.c_str() && endY != value.c_str() + commaPos + 1) { + result.set("found", true); + result.set("x", x); + result.set("y", y); + } + + return result; +} + +void PAGXView::updateZoomScaleAndOffset(float zoom, float offsetX, float offsetY) { + bool zoomChanged = (std::abs(zoom - lastZoom) > 0.001f); + // View changed, invalidate the zoom-out idle token so the next draw() re-evaluates. + zoomedOutFrameSettled = false; + if (zoom <= 1.0f) { + displayList.setSubtreeCacheMaxSize(ZOOMED_OUT_SUBTREE_CACHE_SIZE); + } else { + displayList.setSubtreeCacheMaxSize(0); + } + + if (zoomChanged) { + if (!isZooming) { + isZooming = true; + updateAdaptiveTileRefinement(); + } + + // Direction tracking is owned by tgfx (DisplayList::setZoomScale + the deadband + // accumulator gated on setZoomOutTileThrottlePerFrame). pagx still needs a coarse + // direction signal for the in/out timeout split below (ZOOM_IN_END_TIMEOUT_MS vs + // ZOOM_OUT_END_TIMEOUT_MS), so we derive it from the current single-frame delta. Timeouts + // only fire after several hundred milliseconds of no updates, by which point any single + // jittery frame's direction has long stopped mattering. + isZoomingIn = (zoom > lastZoom); + + lastZoomUpdateTimestampMs = emscripten_get_now(); + } + + displayList.setZoomScale(zoom); + displayList.setContentOffset(offsetX, offsetY); + lastZoom = zoom; +} + +void PAGXView::onZoomEnd() { + if (!isZooming) { + return; + } + + isZooming = false; + + currentMaxTilesRefinedPerFrame = 1; + displayList.setMaxTilesRefinedPerFrame(currentMaxTilesRefinedPerFrame); + + tryUpgradeTimestampMs = emscripten_get_now() + POST_ZOOM_UPGRADE_DELAY_MS; +} + +bool PAGXView::draw() { + if (device == nullptr) { + return false; + } + + double frameStartMs = emscripten_get_now(); + + float liveZoom = static_cast(displayList.zoomScale()); + const auto& liveOffset = displayList.contentOffset(); + + // Idle short-circuit: a previous draw() finished the zoom-out fast path and nothing has + // moved since. The displayList still reports dirty (we never ran render() to drain it), + // but the pixels already on the surface match the current zoom/offset, so we can return + // immediately. Bail out as soon as tgfx reports actual content has changed -- once the + // fast path settled into idle, the only way hasContentChanged() flips back to true is a + // layer-tree mutation (progressive image upgrade swapping a higher-resolution texture, or + // the initial attachNativeImage() attaching pixels to nodes whose first render + // already happened). setZoomScale/setContentOffset cannot trigger this branch because both + // clear zoomedOutFrameSettled via updateZoomScaleAndOffset(); without the dirty guard + // those post-first-render content changes would be silently dropped. A queued + // attachNativeImage() upload neither marks the display list dirty nor clears + // zoomedOutFrameSettled, so we must also bail out of the short-circuit when pendingUploads + // is non-empty (mirrors the `dirty` computation below); otherwise a Full-quality upload + // queued while the view sits idle would never reach flushPendingUploads. + if (snapshotEnabled && zoomedOutFrameSettled && !gestureActive && liveZoom <= ZOOM_DRIFT_MARGIN && + !displayList.hasContentChanged() && pendingUploads.empty()) { + return true; + } + + // Composite-blit fast path. Skips the full displayList.render() pass in two cases: + // 1. Active gesture: user is panning/zooming, full render would cost 200-800ms/frame. + // 2. Steady state at zoom <= 1: fitSnapshot already contains all pixels any render + // would produce at this zoom, so we can safely blit it instead of re-rendering. + // The 0.02 margin above 1.0 absorbs float drift on the user's release zoom. + // + // The non-gesture branch additionally requires displayList not to be dirty and no queued + // uploads: a dirty flag here means the layer tree changed since the snapshot was captured + // (typically a progressive image upgrade or a deferred image attach), and a non-empty + // pendingUploads means a queued attachNativeImage() texture has not been flushed yet + // (queuing marks neither dirty nor clears zoomedOutFrameSettled). Either case must take the + // full-render path so flushPendingUploads runs instead of blitting fitSnapshot over the new + // content. Active gestures override this because the user expects steady pan/zoom feedback + // even when async upgrades land mid-gesture; the snapshot will be refreshed on the next + // non-gesture frame. + bool fitOnly = snapshotEnabled && !gestureActive && fitSnapshot != nullptr && + liveZoom <= ZOOM_DRIFT_MARGIN && !displayList.hasContentChanged() && + pendingUploads.empty(); + if (snapshotEnabled && (gestureActive || fitOnly) && surface != nullptr && + fitSnapshot != nullptr) { + auto context = device->lockContext(); + if (context == nullptr) { + return false; + } + auto canvas = surface->getCanvas(); + canvas->clear(); + if (backgroundVisible) { + DrawSolidBackground(canvas, canvasWidth, canvasHeight, backgroundTGFXColor); + } else { + DrawBackground(canvas, canvasWidth, canvasHeight, 1.0f); + } + // fit blit: fit is an N× supersample of the z=1 main-surface state, so the blit scale = liveZoom/N. + // The enclosing guard already ensures fitSnapshot != nullptr here. + float pixelScale = fitSnapshotPixelScale > 0.0f ? fitSnapshotPixelScale : 1.0f; + float fitScale = liveZoom / pixelScale; + float ftx = liveOffset.x; + float fty = liveOffset.y; + canvas->save(); + canvas->translate(ftx, fty); + canvas->scale(fitScale, fitScale); + canvas->drawImage(fitSnapshot); + canvas->restore(); + auto recording = context->flush(); + if (recording) { + context->submit(std::move(recording)); + } + // Free GL textures retired earlier in the draw (typically by parsePAGX swapping + // documents while the gesture-freeze path was active). Must happen after submit so any + // in-flight render that referenced the texture has already been recorded into the + // command buffer and is safe to deletion. + drainPendingTextureDeletes(); + device->unlock(); + if (!hasRenderedFirstFrame) { + hasRenderedFirstFrame = true; + } + // Mark the zoom-out idle state so future draw() calls can short-circuit until the + // view moves. Only for the non-gesture path -- gestures expect to re-composite each frame. + if (fitOnly) { + zoomedOutFrameSettled = true; + } + return true; + } + + // Capture before rendering so the post-render logging block can identify the first frame. + bool wasFirstFrame = !hasRenderedFirstFrame; + + // Per-stage timings in milliseconds. Zero for stages skipped this frame. All capture sites + // are gated on DRAW_LOG_ENABLED so the emscripten_get_now() probes are fully stripped from + // shipped builds, where the only consumers (the slow-frame / first-frame breakdowns below) + // are compiled out. + [[maybe_unused]] double surfaceMs = 0.0; + [[maybe_unused]] double bgMs = 0.0; + [[maybe_unused]] double renderMs = 0.0; + [[maybe_unused]] double flushMs = 0.0; + [[maybe_unused]] double submitMs = 0.0; + [[maybe_unused]] double unlockMs = 0.0; + [[maybe_unused]] double surfaceStartMs = 0.0; + [[maybe_unused]] double bgStartMs = 0.0; + [[maybe_unused]] double renderStartMs = 0.0; + [[maybe_unused]] double flushStartMs = 0.0; + [[maybe_unused]] double submitStartMs = 0.0; + [[maybe_unused]] double unlockStartMs = 0.0; + // Force a render pass when there are pending GPU uploads so flushPendingUploads runs even on + // an otherwise idle frame; the upload itself will rebuild the affected layers and mark the + // display list dirty for subsequent frames. + bool dirty = displayList.hasContentChanged() || !pendingUploads.empty(); + + if (dirty) { + auto context = device->lockContext(); + if (context == nullptr) { + return false; + } + + // Drain queued attachNativeImage() requests inside the locked context so the texImage2D + // calls go to PAGXView's own GL context. Run before any rendering work; the resulting + // backend-texture images are picked up by displayList.render() through the Image-node + // rebuilds triggered inside flushPendingUploads. + flushPendingUploads(context); + + if constexpr (DRAW_LOG_ENABLED) { + surfaceStartMs = emscripten_get_now(); + } + if (surface == nullptr || surface->width() != canvasWidth || surface->height() != canvasHeight) { + context->setCacheLimit(MAX_CACHE_LIMIT); + context->setResourceExpirationFrames(EXPIRATION_FRAMES); + tgfx::GLFrameBufferInfo glInfo = {}; + glInfo.id = 0; + glInfo.format = GL_RGBA8; + tgfx::BackendRenderTarget renderTarget(glInfo, canvasWidth, canvasHeight); + surface = tgfx::Surface::MakeFrom(context, renderTarget, tgfx::ImageOrigin::BottomLeft); + if (surface == nullptr) { + device->unlock(); + return false; + } + } + if constexpr (DRAW_LOG_ENABLED) { + surfaceMs = emscripten_get_now() - surfaceStartMs; + } + + if constexpr (DRAW_LOG_ENABLED) { + bgStartMs = emscripten_get_now(); + } + auto canvas = surface->getCanvas(); + canvas->clear(); + + if (backgroundVisible) { + DrawSolidBackground(canvas, canvasWidth, canvasHeight, backgroundTGFXColor); + } else { + DrawBackground(canvas, canvasWidth, canvasHeight, 1.0f); + } + if constexpr (DRAW_LOG_ENABLED) { + bgMs = emscripten_get_now() - bgStartMs; + } + + if constexpr (DRAW_LOG_ENABLED) { + renderStartMs = emscripten_get_now(); + } + displayList.render(surface.get(), false); + if constexpr (DRAW_LOG_ENABLED) { + renderMs = emscripten_get_now() - renderStartMs; + } + + if constexpr (DRAW_LOG_ENABLED) { + flushStartMs = emscripten_get_now(); + } + auto recording = context->flush(); + if constexpr (DRAW_LOG_ENABLED) { + flushMs = emscripten_get_now() - flushStartMs; + } + + // Throttled GPU cache footprint probe. context->memoryUsage() reports total bytes held by + // the tgfx ResourceCache (textures + buffers); purgeableBytes is the LRU-evictable subset. + // The SDK-side accounting (external/thumb totals + entry counts + pending uploads) makes it + // possible to tell apart "tgfx is full of backend textures we asked for" from "tgfx is full + // of tile cache or other internal resources" by diffing against memoryUsage. imageCount is + // legacy: it counts only pre-build synchronous loads via attachNativeImage, so the + // post-build backend-texture path does not increment it; expect imageCount=0 in scenarios + // that exclusively use the post-build flow. + if ((gpuProbeCounter++ % GPU_PROBE_INTERVAL) == 0) { + LOGI( + "[MemProbe] tag=gpu.cache memoryUsage=%lluMB purgeable=%lluMB cacheLimit=%lluMB " + "imageCount=%u externalFull=%zu/%lluMB(budget=%lluMB) " + "thumb=%zu/%lluMB(budget=%lluMB) pendingUploads=%zu", + static_cast(context->memoryUsage() / BYTES_PER_MB), + static_cast(context->purgeableBytes() / BYTES_PER_MB), + static_cast(context->cacheLimit() / BYTES_PER_MB), + imageDecodedCount, + externalTextures.size(), + static_cast(externalTexturesTotalBytes / BYTES_PER_MB), + static_cast(fullBudget / BYTES_PER_MB), + thumbnailTextures.size(), + static_cast(thumbnailTexturesTotalBytes / BYTES_PER_MB), + static_cast(thumbnailBudget / BYTES_PER_MB), + pendingUploads.size()); + } + + if (recording) { + if constexpr (DRAW_LOG_ENABLED) { + submitStartMs = emscripten_get_now(); + } + context->submit(std::move(recording)); + if constexpr (DRAW_LOG_ENABLED) { + submitMs = emscripten_get_now() - submitStartMs; + } + if (!hasRenderedFirstFrame) { + hasRenderedFirstFrame = true; + } + // Capture fit the first time there is content after a page switch / first frame. Ultra-wide / + // ultra-tall documents are captured offscreen at high resolution to avoid blurring when zoomed. + bool hasContent = contentLayer != nullptr; + bool fitMissing = fitSnapshot == nullptr; + if (snapshotEnabled && hasContent && fitMissing) { + float fitContentScale = computeFitScale(); + // Memory grows by N² (2x≈57MB, 4x≈230MB); capped at 2x under the iOS 512MB limit. + float pixelScale = fitContentScale < HIGH_RES_FIT_SCALE_THRESHOLD ? HIGH_RES_PIXEL_SCALE : DEFAULT_PIXEL_SCALE; + // Runtime memory budget check: if the high-res snapshot would push total GPU memory + // (existing textures + snapshot) past fullBudget, fall back to DEFAULT_PIXEL_SCALE. + if (pixelScale > DEFAULT_PIXEL_SCALE) { + uint64_t snapshotBytes = static_cast(canvasWidth * pixelScale) * + static_cast(canvasHeight * pixelScale) * + RGBA_BYTES_PER_PIXEL; + uint64_t totalProjected = externalTexturesTotalBytes + thumbnailTexturesTotalBytes + snapshotBytes; + if (totalProjected > fullBudget) { + pixelScale = DEFAULT_PIXEL_SCALE; + } + } + + if (pixelScale > DEFAULT_PIXEL_SCALE) { + int offW = static_cast(canvasWidth * pixelScale); + int offH = static_cast(canvasHeight * pixelScale); + auto offscreen = tgfx::Surface::Make(context, offW, offH); + if (offscreen != nullptr) { + // Temporarily set displayList to zoom=N, offset=0 to render into offscreen, then restore. + float savedZoom = static_cast(displayList.zoomScale()); + tgfx::Point savedOffset = displayList.contentOffset(); + displayList.setZoomScale(pixelScale); + displayList.setContentOffset(0.0f, 0.0f); + auto offCanvas = offscreen->getCanvas(); + offCanvas->clear(); + if (backgroundVisible) { + DrawSolidBackground(offCanvas, offW, offH, backgroundTGFXColor); + } else { + DrawBackground(offCanvas, offW, offH, pixelScale); + } + displayList.render(offscreen.get(), false); + auto offRecording = context->flush(); + if (offRecording) { + context->submit(std::move(offRecording)); + } + fitSnapshot = offscreen->makeImageSnapshot(); + fitSnapshotPixelScale = pixelScale; + // Same as the main surface path: the snapshot may have a deferred copy task attached; + // flush immediately so it completes while the source offscreen is still clean, avoiding + // later accidental overwrites. + auto copyRecording = context->flush(); + if (copyRecording) { + context->submit(std::move(copyRecording)); + } + displayList.setZoomScale(savedZoom); + displayList.setContentOffset(savedOffset.x, savedOffset.y); + } else { + fitSnapshot = surface->makeImageSnapshot(); + fitSnapshotPixelScale = DEFAULT_PIXEL_SCALE; + } + } else { + fitSnapshot = surface->makeImageSnapshot(); + fitSnapshotPixelScale = DEFAULT_PIXEL_SCALE; + // makeImageSnapshot on an externallyOwned surface (mini-program main canvas) creates an + // asynchronous copy task. Without an immediate flush, this copy is deferred to the next + // draw, by which time the surface pixels may already be overwritten by a new render, + // causing the snapshot to capture wrong content. + auto snapRecording = context->flush(); + if (snapRecording) { + context->submit(std::move(snapRecording)); + } + } + } + } + + // Free GL textures retired during this draw (eviction in enforceFullBudget, replacement + // upload in flushPendingUploads, or document swap in parsePAGX). Must happen after every + // flush+submit above so the command buffers that referenced the texture have all been + // built and queued; WebGL keeps in-flight commands valid even after deleteTexture. + drainPendingTextureDeletes(); + + if constexpr (DRAW_LOG_ENABLED) { + unlockStartMs = emscripten_get_now(); + } + device->unlock(); + if constexpr (DRAW_LOG_ENABLED) { + unlockMs = emscripten_get_now() - unlockStartMs; + } + } + + double frameEndMs = emscripten_get_now(); + double frameDurationMs = frameEndMs - frameStartMs; + + // Log only frames that exceed the slow-frame threshold. The post-slow follow-up mechanism + // below is kept as scaffolding for future debugging, but disabled here to keep the log clean. + bool isSlowFrame = frameDurationMs > SLOW_FRAME_LOG_THRESHOLD_MS; + bool shouldLogFrame = isSlowFrame; + + if constexpr (DRAW_LOG_ENABLED) { + if (shouldLogFrame) { + const auto& offset = displayList.contentOffset(); + float fitScale = computeFitScale(); + float effectiveScale = fitScale * displayList.zoomScale(); + // Expected drawn content size in canvas pixels (before viewport clipping). Anything much + // larger than the canvas itself means most fragment work is wasted off-screen. + float drawWidthPx = pagxWidth * effectiveScale; + float drawHeightPx = pagxHeight * effectiveScale; + LOGI( + "[PAGXView] slow frame: total=%.2fms surface=%.2fms bg=%.2fms render=%.2fms " + "flush=%.2fms submit=%.2fms unlock=%.2fms dirty=%d zoom=%.4f quantized=%.4f " + "offset=(%.1f,%.1f) fitScale=%.4f effScale=%.4f canvas=(%dx%d) pagx=(%.0fx%.0f) " + "drawPx=(%.0fx%.0f)", + frameDurationMs, surfaceMs, bgMs, renderMs, flushMs, submitMs, unlockMs, dirty ? 1 : 0, + lastZoom, displayList.zoomScale(), offset.x, offset.y, fitScale, effectiveScale, canvasWidth, + canvasHeight, pagxWidth, pagxHeight, drawWidthPx, drawHeightPx); + } + } + + // First-frame breakdown for telemetry. + if constexpr (DRAW_LOG_ENABLED) { + if (wasFirstFrame && hasRenderedFirstFrame) { + LOGI( + "[PAGXView] first frame: total=%.2fms surface=%.2fms bg=%.2fms render=%.2fms " + "flush=%.2fms submit=%.2fms unlock=%.2fms", + frameDurationMs, surfaceMs, bgMs, renderMs, flushMs, submitMs, unlockMs); + } + } + + updatePerformanceState(frameDurationMs); + + if (isZooming && lastZoomUpdateTimestampMs > 0.0) { + double currentTimeoutMs = isZoomingIn ? ZOOM_IN_END_TIMEOUT_MS : ZOOM_OUT_END_TIMEOUT_MS; + double timeSinceLastUpdate = frameStartMs - lastZoomUpdateTimestampMs; + + if (timeSinceLastUpdate >= currentTimeoutMs) { + onZoomEnd(); + } + } + + if (!isZooming) { + if (tryUpgradeTimestampMs > 0.0) { + if (frameStartMs >= tryUpgradeTimestampMs) { + if (!lastFrameSlow) { + int targetCount = calculateTargetTileRefinement(lastZoom); + currentMaxTilesRefinedPerFrame = targetCount; + displayList.setMaxTilesRefinedPerFrame(targetCount); + tryUpgradeTimestampMs = 0.0; + } else { + tryUpgradeTimestampMs = frameStartMs + UPGRADE_RETRY_DELAY_MS; + } + } + } else { + updateAdaptiveTileRefinement(); + } + } + + // Backend-texture cache bookkeeping. The call runs after lockContext has been released + // because it only mutates provider and tgfx layer state, never GL state. enforceFullBudget + // sweeps the full bucket against fullBudget; entries pushed past the budget are removed from + // the provider and the next draw falls back to thumbnail. + enforceFullBudget(); + // After eviction, any path that just lost its full texture is now a candidate for the + // request scan. Emitting onTextureRequest on the same draw shortens the time-to-recovery + // for the host: the download can start one frame earlier than if we waited until the next + // frame to scan. + scanAndRequestMissingTextures(); + // Bump the frame counter last so all bookkeeping above shares the same currentFrameIndex + // and the next attachNativeImage() / draw() sees a strictly larger value. + currentFrameIndex += 1; + + return true; +} + +bool PAGXView::firstFrameRendered() const { + return hasRenderedFirstFrame; +} + +void PAGXView::resetForFreshCapture() { + // On a page switch the TS side first calls parsePAGX/buildLayers, then gestureManager.init + + // applyGestureState. Between these two steps RAF may run a full render and capture cached/fit in + // the intermediate "not yet init" view state. This method lets the TS side discard those temporary + // snapshots after init completes, reset the first-frame flag, and wait for a new first frame — the + // snapshot captured then is the initial picture the user actually sees. + fitSnapshot = nullptr; + fitSnapshotPixelScale = DEFAULT_PIXEL_SCALE; + hasRenderedFirstFrame = false; + zoomedOutFrameSettled = false; +} + +int PAGXView::width() const { + return canvasWidth; +} + +int PAGXView::height() const { + return canvasHeight; +} + +void PAGXView::updatePerformanceState(double frameDurationMs) { + double now = emscripten_get_now(); + + if (frameDurationMs > SLOW_FRAME_THRESHOLD_MS) { + if (!lastFrameSlow) { + frameHistory.clear(); + frameHistoryTotalTime = 0.0; + } + lastFrameSlow = true; + } + + frameHistory.push_back({now, frameDurationMs}); + frameHistoryTotalTime += frameDurationMs; + + double windowStart = now - RECOVERY_WINDOW_MS; + while (!frameHistory.empty() && frameHistory.front().timestampMs < windowStart) { + frameHistoryTotalTime -= frameHistory.front().durationMs; + frameHistory.pop_front(); + } + + if (lastFrameSlow && !frameHistory.empty()) { + double avgTime = frameHistoryTotalTime / static_cast(frameHistory.size()); + size_t minFrames = isZooming ? MIN_RECOVERY_FRAMES_ZOOM_END : MIN_RECOVERY_FRAMES_STATIC; + if (avgTime <= SLOW_FRAME_THRESHOLD_MS && frameHistory.size() >= minFrames) { + lastFrameSlow = false; + } + } +} + +int PAGXView::calculateTargetTileRefinement(float zoom) const { + // Performance-first strategy: disable tile refinement during zooming to reduce stutter + if (isZooming) { + return 0; + } + + if (lastFrameSlow) { + return 1; + } + + if (zoom < 1.0f) { + int count = static_cast(zoom / TILE_REFINEMENT_ZOOM_DIVISOR) + 1; + return std::clamp(count, 1, MAX_TILE_REFINEMENT); + } + + return MAX_TILE_REFINEMENT; +} + +void PAGXView::updateAdaptiveTileRefinement() { + int targetCount = calculateTargetTileRefinement(lastZoom); + + if (targetCount != currentMaxTilesRefinedPerFrame) { + currentMaxTilesRefinedPerFrame = targetCount; + displayList.setMaxTilesRefinedPerFrame(targetCount); + } +} + +} // namespace pagx diff --git a/pagx/wechat/src/PAGXView.h b/pagx/wechat/src/PAGXView.h new file mode 100644 index 0000000000..fef5b24386 --- /dev/null +++ b/pagx/wechat/src/PAGXView.h @@ -0,0 +1,691 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include "tgfx/core/Color.h" +#include "tgfx/core/Image.h" +#include "tgfx/gpu/Recording.h" +#include "tgfx/layers/DisplayList.h" +#include "pagx/FontConfig.h" +#include "pagx/PAGXDocument.h" +#include "PAGXImageResourceProvider.h" +#include "utils/StringParser.h" +#include "LayerBuilder.h" + +namespace tgfx { +class Context; +} + +namespace pagx { + +/** + * Quality tier for an externally supplied native image attached via + * PAGXView::attachNativeImage(). The two tiers are managed by PAGXImageResourceProvider and obey + * different lifecycle rules: + * + * - Thumbnail: low-resolution preview (typically <= 256x256). Stored in the provider's + * thumbnail cache. Acts as a fallback when the full-resolution texture is missing or has + * been evicted, so the affected fill area never goes blank during progressive loading or + * memory pressure. Thumbnails are not subject to per-frame LRU eviction; they are only + * dropped when an incoming attachNativeImage() would push the thumbnail bucket over its + * byte budget, in which case the oldest thumbnails are silently dropped to make room. + * + * - Full: full-resolution texture. Stored in the provider's full-quality cache. Subject to + * per-frame LRU eviction once the full bucket exceeds its byte budget; evicted paths fire + * the onTextureEvict callback so the host can drop its own cached copy. Layers whose full + * texture has been evicted automatically fall back to the corresponding thumbnail on the + * next draw, keeping the fill visible until a new full texture arrives. + */ +enum class ImageQuality : uint8_t { + Thumbnail = 0, + Full = 1, +}; + +class PAGXView { + public: + /** + * Creates a PAGXView instance for WeChat Mini Program rendering. + * @param width The width of the canvas in pixels. + * @param height The height of the canvas in pixels. + * @return A shared pointer to the created PAGXView, or nullptr if creation fails. + * + * Note: Before calling this method, the JavaScript code must: + * 1. Get the Canvas object from WeChat API + * 2. Call canvas.getContext('webgl2') to get WebGL2RenderingContext + * 3. Register the context via GL.registerContext(gl) + */ + static std::shared_ptr MakeFrom(int width, int height); + + /** + * Constructs a PAGXView with the given device and canvas dimensions. + */ + PAGXView(std::shared_ptr device, int width, int height); + + /** + * Releases every backend texture this view still owns. Run while the GL context the view + * was bound to is still current — the JS-side destroy() handles makeCurrent/clearCurrent + * around nativeView.delete(), so this destructor sees a valid GL state and can call + * gl.deleteTexture directly. + */ + ~PAGXView(); + + /** + * Registers fallback fonts for text rendering. + * @param fontVal Font file data as a JavaScript Uint8Array (e.g. NotoSansSC-Regular.otf). + * @param emojiFontVal Emoji font file data as a JavaScript Uint8Array (e.g. NotoColorEmoji.ttf). + */ + void registerFonts(const emscripten::val& fontVal, const emscripten::val& emojiFontVal); + + /** + * Loads a PAGX file from the given data and builds the layer tree for rendering. + * This is a convenience method equivalent to calling parsePAGX() followed by buildLayers(). + * @param pagxData PAGX file data as a JavaScript Uint8Array. + */ + void loadPAGX(const emscripten::val& pagxData); + + /** + * Parses a PAGX file from the given data without building the layer tree. After parsing, call + * getExternalFilePaths() to retrieve external resources, load them with loadFileData(), then + * call buildLayers() to finalize rendering. + * @param pagxData PAGX file data as a JavaScript Uint8Array. + */ + void parsePAGX(const emscripten::val& pagxData); + + /** + * Returns external file paths referenced by Image nodes that have no embedded data. Data URIs + * are excluded. Call this after parsePAGX() to determine which files need to be fetched. + */ + std::vector getExternalFilePaths() const; + + /** + * Loads external file data for an Image node matching the given file path. The data is embedded + * into the matching node so the renderer uses it directly instead of file I/O. + * @param filePath The external file path to match against Image nodes. + * @param fileData The file content as a JavaScript Uint8Array. + * @return True if a matching Image node was found and its data was loaded successfully. + */ + bool loadFileData(const emscripten::val& filePathVal, const emscripten::val& fileData); + + /** + * Attaches a host-decoded native image (typically an OffscreenCanvas) to Image nodes matching + * the given file path under a specific quality tier. The image is queued for GPU texture + * upload on the next draw() call (inside lockContext); the OffscreenCanvas can be released as + * soon as this method returns. Affected layers are rebuilt in place after the upload so the + * new asset shows up on the same frame. + * + * The quality argument selects which slot on the Image node receives the result: + * - ImageQuality::Thumbnail stores in the provider's thumbnail cache (fallback rendering). + * - ImageQuality::Full stores in the provider's full-quality cache (primary rendering). + * + * Both quality tiers may be attached for the same filePath at different times; they live in + * distinct caches and do not overwrite each other. Can be called both before and after + * buildLayers(); the upload and rebuild happen at draw() time regardless. + * + * @param filePathVal The external file path to match against Image nodes. + * @param nativeImage A JavaScript-side decoded image source (OffscreenCanvas, ImageBitmap, or + * similar). Must be truthy. + * @param qualityRaw The image quality as the integer value of ImageQuality, passed through as + * a plain int because the JS binding cannot pass enum values directly. + * @return True if the request was queued for upload. + */ + bool attachNativeImage(const emscripten::val& filePathVal, const emscripten::val& nativeImage, + int qualityRaw); + + /** + * Registers a JavaScript handler that receives backend-texture lifecycle events from this + * view. The handler object is expected to expose two methods (both optional; missing methods + * are silently skipped): + * + * - onTextureRequest(filePath: string): called for paths whose full-quality image + * was previously attached and then evicted by the LRU sweep, until a new + * attachNativeImage(Full) lands. Initial-attachment paths (full image never present) + * are NOT surfaced here — those are owned by the host's own progressive image flow, + * bootstrapped from getExternalFilePaths(). The host should respond by downloading + + * decoding the asset and calling attachNativeImage(filePath, source, ImageQuality.Full); + * doing so atomically clears the in-flight marker for that path so further draws will + * re-request only if the asset is evicted again later. To prevent per-frame spam, each + * evicted path is only emitted once until the host responds via attachNativeImage(Full); + * hosts that drop a request without responding will not see it re-fired (and the renderer + * will keep falling back to thumbnail indefinitely). + * + * - onTextureEvict(filePaths: string[]): called once per draw with every full-quality path + * that was just dropped by the LRU sweep. The host can use this hint to release the + * corresponding bytes from its own cache. Thumbnail evictions never fire this callback. + * + * Passing a falsy value (undefined / null) clears any previously registered handler. The + * registration survives parsePAGX(), so callers only need to set it once after MakeFrom(). + */ + void setTextureEventHandler(const emscripten::val& handler); + + /** + * Reports whether the full-quality cache has crossed its hard cap. Hosts driving a + * progressive thumbnail-to-full upgrade flow should consult this at the top of every + * upgrade pass and skip new uploads while it returns true. Reactive responses to + * onTextureRequest are still safe because each one replaces an entry that has just been + * evicted, leaving total bytes net-flat. + * + * The flag is purely a function of externalTexturesTotalBytes vs fullBudgetHardCap; it + * clears automatically as soon as a viewport change lets the LRU drain enough off-viewport + * entries to fall back below the cap. Hosts do not need to subscribe to a separate + * "memory recovered" callback: the next gesture-triggered evaluate() naturally re-checks + * this method and resumes upgrades. + */ + bool isFullBudgetSaturated() const; + + + + /** + * Returns root-space bounds (canvas pixel coordinates, already accounting for fitScale and + * the centering offset applied to contentLayer) for every filePath in the input list. Each + * returned entry has both a unionBounds (axis-aligned union of every referencing layer, used + * for viewport-intersection tests) and a largestBounds (the single referencing layer with the + * biggest area, used for focus-distance scoring). filePaths with no matching layer map to + * null so callers can tell apart "off-canvas" from "no such filePath". + * + * Must be called after buildLayers() so the contentLayer has been attached to the + * displayList; otherwise the result is an empty object. The first call triggers the tgfx + * layer tree to lazily compute and cache each layer's localBounds (estimated at 50-250ms + * total for a ~50-image document), so it is cheaper to defer the call to a moment when the + * user is already waiting (e.g. the first idle window after the initial frame has rendered) + * rather than running it synchronously alongside buildLayers(). + * + * @param filePathList A JavaScript Array of file path strings. + * @return A JavaScript object keyed by filePath. Each value is either + * { unionBounds: {x,y,w,h}, largestBounds: {x,y,w,h} } or null. + */ + emscripten::val getImageBounds(const emscripten::val& filePathList) const; + + /** + * Returns the ImagePattern usage metadata for every externally referenced image. The return + * value lets the JS layer size thumbnail downloads and compute display scale without having + * to re-parse the PAGX XML on its own; everything needed (original image dimensions, target + * node dimensions, scale mode, scaleFactor for tiled patterns) is forwarded from the + * ImagePattern::customData entries already populated by PAGXImporter. + * + * Must be called after parsePAGX() so customData has been filled in; calling earlier + * returns an empty array. Data URIs and inline data-backed Image nodes are excluded -- only + * Images with a non-empty filePath participate. + * + * @return A JavaScript Array of entries: + * [ { filePath, origWidth, origHeight, + * usages: [ { nodeWidth, nodeHeight, scaleMode, scaleFactor }, ... ] }, ... ] + */ + emscripten::val getImageMetadata() const; + + /** + * Records the original (authoring-time) pixel dimensions of an external image referenced by + * filePath. New PAGX exports bake their ImagePattern.matrix in the original-image pixel + * coordinate system, so when the host attaches a downscaled CDN variant via + * attachNativeImage() the renderer needs to know the original dimensions to post-correct the + * baked matrix against the actual attached pixels. + * + * Call this once per external image after parsePAGX() and before buildLayers() / the first + * attachNativeImage() so ResolveImagePatternMatricesByFilePath sees the original size and + * can apply the diag(origW/actualW, origH/actualH) post-scale to keep visual placement + * stable across thumbnail/full quality swaps. + * + * Subsequent calls overwrite the previous record. parsePAGX() clears the entire table. + * + * @param filePath The external file path matching pagx::Image::filePath. Must be non-empty. + * @param width Original image width in pixels (> 0). + * @param height Original image height in pixels (> 0). + */ + void setImageOriginalSize(const emscripten::val& filePathVal, float width, float height); + + /** + * Builds the layer tree from the previously parsed document. Call this after parsePAGX() and + * any loadFileData() calls to finalize the rendering content. + */ + void buildLayers(); + + /** + * Updates the canvas size and recreates the surface. + * @param width New width in pixels. + * @param height New height in pixels. + */ + void updateSize(int width, int height); + + /** + * Updates the zoom scale and content offset for the display list. + * @param zoom The current zoom scale factor. + * @param offsetX The horizontal content offset in pixels. + * @param offsetY The vertical content offset in pixels. + */ + void updateZoomScaleAndOffset(float zoom, float offsetX, float offsetY); + + /** + * Notifies that zoom gesture has ended. This will restore tile refinement. + * Note: This is called automatically by internal detection, no need to call manually. + */ + void onZoomEnd(); + + /** + * Renders the current frame to the canvas. Returns true if the rendering succeeds. + */ + bool draw(); + + /** + * Returns true if the first frame has been rendered successfully. + */ + bool firstFrameRendered() const; + + /** + * Resets internal "first frame rendered" / fit snapshot state so callers can wait for a + * fresh first frame after applying gesture state on a newly loaded document. Use after + * parsePAGX + buildLayers + applyGestureState to ensure the upcoming first frame is + * captured against the final view state, not against the transient post-buildLayers + * default zoom/offset. + */ + void resetForFreshCapture(); + + /** + * Returns the width of the PAGX content in content pixels. + */ + float contentWidth() const { + return pagxWidth; + } + + /** + * Returns the height of the PAGX content in content pixels. + */ + float contentHeight() const { + return pagxHeight; + } + + /** + * Sets the bounds origin of the PAGX content relative to the cocraft canvas origin. This + * overrides any values read from the document's customData. The values can be negative. + * @param x The x coordinate of the content bounds origin in cocraft canvas coordinates. + * @param y The y coordinate of the content bounds origin in cocraft canvas coordinates. + */ + void setBoundsOrigin(float x, float y); + + /** + * Enables/disables gesture-freeze rendering. When enabled, draw() stops calling + * displayList.render() and instead blits the last fully-rendered frame to the surface, + * applying the delta between the snapshot's zoom/offset and the current ones. This + * eliminates per-frame shape retriangulation and slow-op execution for the duration of a + * user pan/zoom, at the cost of visual blur when zooming beyond the snapshot scale. + * + * The caller (JavaScript gesture layer) is responsible for calling setGestureActive(true) + * on zoomStart/panStart and setGestureActive(false) on zoomEnd/panEnd. The first draw() + * after disabling freezes performs a normal render, producing a "refocus" frame that is + * expected to be slow; subsequent idle frames are unaffected. + * + * No-op if a snapshot is unavailable (e.g. before the first frame has rendered). Freeze + * never latches across documents: parsePAGX/buildLayers clear the cached snapshot. + */ + void setGestureActive(bool active); + + /** + * Toggles the fitSnapshot fast path. When enabled (default), draw() blits a cached + * fit-to-canvas snapshot during gestures and at zoom <= 1.02 to avoid the cost of a full + * displayList.render(). When disabled, draw() always runs a full render and never captures + * a fit snapshot, trading per-frame rendering cost for first-frame clarity and freshness + * under progressive image loading. + * + * Disabling drops any existing fit snapshot and resets the zoom-out idle short-circuit so + * the next draw() runs a full render. The setting persists across parsePAGX/buildLayers. + */ + void setSnapshotEnabled(bool enabled); + + /** + * Returns the content transform parameters needed for mapping cocraft canvas coordinates to + * canvas pixel positions. The returned JavaScript object contains: + * - boundsOriginX/Y: PAGX content origin in cocraft canvas coordinates. + * - fitScale: scale factor applied to fit PAGX content into the canvas (contain mode). + * - centerOffsetX/Y: pixel offset for centering the scaled content in the canvas. + * + * Usage: baseX = (cocraftX - boundsOriginX) * fitScale + centerOffsetX + */ + emscripten::val getContentTransform() const; + + /** + * Looks up a node by ID and returns its position relative to the canvas. + * The position is read from the node's "page-offset" custom data in "x,y" format. + * @param nodeId The unique identifier of the node to look up. + * @return A JavaScript object with { found: boolean, x: number, y: number }. + * If the node is not found or has no position data, found is false and x/y are 0. + */ + emscripten::val getNodePosition(const std::string& nodeId) const; + + /** + * Returns the width of the canvas in pixels. + */ + int width() const; + + /** + * Returns the height of the canvas in pixels. + */ + int height() const; + + private: + void applyCenteringTransform(); + + void applyDocumentCustomData(); + + // Shared contain-mode fit scale used by both the content matrix and the JS-facing content + // transform. Keeping a single source of truth prevents comment pins from drifting relative to + // the rendered content. + float computeFitScale() const; + + void updatePerformanceState(double frameDurationMs); + + void updateAdaptiveTileRefinement(); + + int calculateTargetTileRefinement(float zoom) const; + + std::shared_ptr device = nullptr; + std::shared_ptr surface = nullptr; + tgfx::DisplayList displayList = {}; + std::shared_ptr contentLayer = nullptr; + std::shared_ptr document = nullptr; + std::shared_ptr imageProvider = nullptr; + + // Gesture-freeze pipeline. During active pan/zoom we bypass displayList.render() and + // composite fitSnapshot (whole document at zoom=1, blurry when zoomed in but always fills + // the viewport) onto the surface. Cleared on parsePAGX/updateSize. + bool gestureActive = false; + std::shared_ptr fitSnapshot = nullptr; + // Supersampling factor of the fit snapshot: 1 = same resolution; >1 = N× pixel density, used to + // keep ultra-wide / ultra-tall documents sharp. + float fitSnapshotPixelScale = 1.0f; + // Idle token for the zoom-out fast path: once draw() has painted the current view from + // fitSnapshot alone, further idle frames short-circuit. Cleared by any view change. + bool zoomedOutFrameSettled = false; + // Master switch for the fitSnapshot fast path. When false, draw() always performs a full + // displayList.render() and never captures a fit snapshot. Toggled via setSnapshotEnabled(). + bool snapshotEnabled = true; + // Timestamp (emscripten_get_now ms) of the most recent gesture end. Used to defer the refresh of + // fitSnapshot until the user truly stops: during dense gestures it reuses the last stable captured + // snapshot, avoiding capturing a transitional frame that contains tile fallback and avoiding the + // memory churn of consecutive full renders. + // 0 means a gesture has never happened (before the first frame); the first-frame capture is not gated. + double lastGestureEndMs = 0.0; + // Session owns the LayerBuilder state so attachNativeImage() can regenerate a subset of the + // layer tree when a higher-resolution asset replaces the thumbnail attached during the initial + // buildLayers() pass. Destroyed on every parsePAGX() so old build state never leaks across + // documents. + std::unique_ptr builderSession = nullptr; + + // Pending GPU upload requested by attachNativeImage(). Each entry parks the JS-side decoded + // image (typically an OffscreenCanvas) until the next draw() so the actual texImage2D call + // happens inside lockContext, where the WebGL context is bound to the renderer's GL state. + // The JS reference held in nativeImage keeps the source alive across the gap between the + // attach call and the draw that consumes it. width/height/sizeBytes are sampled once at + // queue time (single wasm↔JS round-trip per attach) so the budget check and the eventual + // flush can both reuse them without bouncing back through emscripten::val. + struct PendingUpload { + std::string filePath; + emscripten::val nativeImage; + ImageQuality quality = ImageQuality::Full; + int width = 0; + int height = 0; + uint64_t sizeBytes = 0; + }; + std::vector pendingUploads = {}; + // Running sum of sizeBytes for thumbnail-quality entries currently in pendingUploads. Lets + // attachNativeImage(Thumbnail) compare against thumbnailBudget in O(1) without re-walking + // the queue (and without bouncing into JS for width/height). Maintained alongside every + // push_back / clear / swap that touches pendingUploads. + uint64_t pendingThumbnailBytes = 0; + // Mirror of pendingThumbnailBytes for full-quality entries. Used by attachNativeImage(Full) + // to project the post-flush total before queuing so we can run a proactive + // enforceFullBudget() pass and avoid the "attach 8 fulls → enforceFullBudget evicts in + // bulk after flush" two-phase oscillation that wasted both upload bandwidth and triggered + // visible thumbnail-fallback flicker on the freshly evicted paths. + uint64_t pendingFullBytes = 0; + + // Backing storage for backend-texture images uploaded by the host. Each entry pins one + // tgfx::Image wrapping a GL texture that this view owns directly — tgfx is told the texture + // is non-adopted (Image::MakeFrom, not MakeAdopted) so the underlying TextureView is + // dropped from the ResourceCache the moment our shared_ptr releases, instead of being held + // back in the scratch pool to wait for an LRU sweep. textureId stores the GL.textures index + // returned by createBackendTexture so the eviction path can hand it to destroyBackendTexture + // and free the GPU memory in the same draw that drops the entry. sizeBytes is the + // approximate GPU footprint at RGBA8 plus a 1/3 mipmap multiplier; it is used for budget + // accounting. LRU rank is no longer carried per-entry: enforceFullBudget() ranks candidates + // on demand by their distance to the viewport center, and approximating "actively used" by + // "still attached" turned out to mark every entry as equally fresh, which silently disabled + // LRU pressure entirely. + struct ExternalTextureEntry { + std::shared_ptr image = nullptr; + uint64_t sizeBytes = 0; + unsigned textureId = 0; + }; + // Full-quality cache: subject to per-frame LRU eviction once externalTexturesTotalBytes + // exceeds fullBudget. Eviction fires the onTextureEvict callback. + std::unordered_map externalTextures = {}; + uint64_t externalTexturesTotalBytes = 0; + // Thumbnail cache: not evicted on a per-frame basis. Only contracts when an incoming + // attachNativeImage() would push thumbnailTexturesTotalBytes past thumbnailBudget, at which + // point the oldest thumbnails are silently dropped. Thumbnails always survive when the full + // bucket evicts, so the fill area can keep showing the blurry preview. + std::unordered_map thumbnailTextures = {}; + uint64_t thumbnailTexturesTotalBytes = 0; + // Tunable byte budgets. Sized against the 512MB tgfx cacheLimit on iOS WeChat (raising + // tgfx beyond 512MB triggered OOM kills in production, so the limit is fixed). The full + // bucket runs a two-tier policy: + // - fullBudget (256MB): soft cap. The LRU starts evicting off-viewport entries as soon as + // totalBytes crosses this line, but uploads are still allowed: the buffer between + // soft and hard caps lets visible-but-not-yet-evicted entries cohabit with newly + // attached fulls without immediately stalling the upgrade pipeline. Sized so the + // remaining cacheLimit headroom (512 - 256 - 56 = 200MB) absorbs tgfx's tile/surface + // cache growth during zoom-in: production logs showed the prior 320MB cap leaving only + // ~136MB for tile cache, which got exhausted (memoryUsage hit 509MB on a 512MB cap) + // when zooming into image-heavy PAGX documents. + // - fullBudgetHardCap (384MB): hard cap. Once totalBytes reaches this line the host's + // evaluate() loop is expected to stop issuing new full-quality upgrades (queried via + // isFullBudgetSaturated()). Reactive responses to onTextureRequest still proceed — + // those are 1:1 replacements for evicted entries that the renderer is already + // referencing, so they can't push totalBytes any higher than it was before the evict. + // Recovery is automatic: a viewport change causes the LRU to evict newly off-viewport + // entries, totalBytes drops back below the hard cap, and the saturation flag clears. + // + // 56MB for thumbnails covers ~168 256x256 previews — more than any realistic document + // references. When thumbnailBudget is exhausted the oldest thumbnails are silently + // dropped without a host callback; affected layers may render blank until a fresh + // full-quality attach lands. + size_t fullBudget = 256ULL * 1024 * 1024; + size_t fullBudgetHardCap = 384ULL * 1024 * 1024; + size_t thumbnailBudget = 56ULL * 1024 * 1024; + // Monotonic counter incremented at the end of every draw(). Used by the eviction-overflow + // warning to throttle log output to once per 60 frames. + uint64_t currentFrameIndex = 0; + // Frame index of the last "fullBudget exceeded with all paths viewport-protected" warning. + // Used by enforceFullBudget to throttle the log to once per 60 frames so a sustained + // overflow does not flood the console. + uint64_t lastFullBudgetOverflowWarnFrame = 0; + + // JS-side event handler registered via setTextureEventHandler(). Default-constructed val is + // undefined; we treat both undefined and null as "no handler". The handler object is held + // by value here; emscripten::val keeps a reference into the JS heap that prevents garbage + // collection until this PAGXView is destroyed (or the handler is replaced). + emscripten::val textureEventHandler = emscripten::val::undefined(); + // Paths for which onTextureRequest has been emitted but no matching attachNativeImage(Full) + // call has resolved yet. Prevents the per-draw scan from re-requesting the same asset on + // every frame while a download is in flight. Cleared on parsePAGX() so a new document + // starts with a fresh request budget. + std::unordered_set inFlightFullRequests = {}; + + // Paths whose full-quality image was previously attached and then evicted by the LRU + // sweep. The renderer uses this set to distinguish "never attached" from "attached and + // evicted" — only the latter should drive new onTextureRequest events, because the former is + // already covered by the host's initial getExternalFilePaths() / attachNativeImage flow and + // surfacing it again would re-request the asset every frame during the perfectly normal + // pre-attach window. Cleared on parsePAGX() and on each successful attachNativeImage(Full). + std::unordered_set evictedFullPaths = {}; + + // Original (authoring-time) pixel dimensions for external images, populated by the host + // through setImageOriginalSize(). New PAGX exports bake ImagePattern.matrix in the + // original-image pixel coordinate system; when the host attaches a downscaled CDN variant + // (thumbnail or scaled full) the renderer reads this map to post-correct the baked matrix + // against the attached image's actual pixel dimensions, keeping visual placement stable + // regardless of which CDN variant landed. + // + // Cleared on parsePAGX(); the host is responsible for repopulating it before buildLayers() + // and any subsequent attachNativeImage() so resolve passes have the data they need. + std::unordered_map> imageOriginalSizes = {}; + + // GL.textures indices retired during the current draw (eviction, replacement upload, or + // document reset) but not yet handed to gl.deleteTexture. drainPendingTextureDeletes() + // empties this list at the very end of draw(), after flush + submit have run, so the + // GL command buffer that used these textures has already been built and submitted to the + // driver. WebGL guarantees in-flight commands keep working with a deleted texture id; only + // future bind calls fail (which is exactly what we want — the entry is already gone from + // externalTextures / thumbnailTextures and no further sampling can reference it). + std::vector pendingTextureDeletes = {}; + + // Records textureId for retirement at the next drainPendingTextureDeletes(). 0 is treated + // as "no id" and silently dropped so callers do not need to special-case empty entries. + void retireTextureId(unsigned textureId); + + // Hands every queued texture id to JS gl.deleteTexture and clears pendingTextureDeletes. + // Must run on the GL thread inside an active lockContext window so the call sequence races + // with no other GL work; called twice in draw() (once per lockContext branch) so the GPU + // memory backing an evicted texture is freed in the same frame it was retired. + void drainPendingTextureDeletes(); + + // Drains pendingUploads inside lockContext. For each request: registers a new GL texture id + // via the JS helper, wraps it in a non-adopted tgfx::Image (Image::MakeFrom) so tgfx never + // claims ownership of the GL texture, writes it to the matching Image node, and bookkeeps + // the entry into externalTextures or thumbnailTextures by quality. The wasm side keeps the + // textureId on the entry so retireTextureId() can reclaim the GPU memory directly when the + // entry is later evicted or replaced. Called from draw() after lockContext() succeeds and + // before any rendering happens. + void flushPendingUploads(tgfx::Context* context); + + // Evicts full-quality entries until externalTexturesTotalBytes drops at or below fullBudget. + // Candidates are restricted to paths whose union bounds fall outside the (1.2x-expanded) + // viewport — visible paths are hard-protected so the user never sees a texture they're + // looking at disappear. Off-viewport candidates are sorted by distance from the viewport + // center descending (farther first), modeled on tgfx's tiled rasterization which uses the + // same focal-distance ordering. For each evicted path: removes the cache entry (releasing + // the adopted backend texture), removes the full image from the provider, and rebuilds + // the affected layers so the next frame falls back to thumbnail. + // + // When every cache entry is inside the protected viewport the budget is allowed to overflow + // temporarily — evicting a visible texture would only cause it to be re-fetched and re- + // attached the same frame, oscillating against itself. A 60-frame-throttled warning is + // logged in this case so sustained overflow stays visible without flooding the console. + void enforceFullBudget(); + + // Computes the current viewport rectangle in displayList.root() coordinates. The result is + // suitable for direct intersection with the bounds returned by Layer::getBounds(rootLayer). + // Returns an empty rect when the canvas is unavailable or the live zoom is non-positive + // (degenerate transform); callers must treat empty as "no viewport protection possible". + tgfx::Rect computeViewportInRootCoords() const; + + // Collects the union of layer bounds (in displayList.root() coordinates) for every filePath + // currently sitting in externalTextures. Used by enforceFullBudget for two consecutive + // decisions: (1) whether each path intersects the viewport (visibility protection) and (2) + // how far the off-viewport paths sit from the viewport center (eviction priority). Sharing + // a single pass keeps the cost of one full bounds-collection low — every getBounds() call + // is cached by tgfx after the first hit, so repeated invocations across frames are O(1). + // Paths whose layers all return empty bounds are absent from the result. + std::unordered_map computeFullPathBounds() const; + + // Attempts to free at least neededBytes from the thumbnail cache by evicting entries in + // hash-map iteration order until the requested amount is freed (or the cache is empty). + // Returns the number of bytes actually freed; the caller is responsible for re-checking + // that the upload can now fit. Eviction is silent (no host callback). + // + // Thumbnails do not get the per-entry recency / viewport-distance ranking that the full + // bucket gets: the cache is sized to comfortably hold every thumbnail in any realistic + // document (56MB / ~340KB per entry ≈ 168 thumbnails), so the eviction path is a true + // last-resort fallback. Spending complexity on a smarter ordering here would have no + // observable benefit. + uint64_t evictOldestThumbnailsUntilFits(uint64_t neededBytes); + + // Walks every Image node and emits onTextureRequest(filePath) for paths that are referenced + // by the document but currently lack a full-quality image in the provider and have not already been + // requested in a previous draw. Triggered once per draw after the LRU sweep so freshly + // evicted paths immediately re-enter the in-flight set on the same frame. + void scanAndRequestMissingTextures(); + + // Invokes the JS handler's onTextureRequest hook with the given filePath. No-op when the + // handler has not been registered or does not expose this method. Called from + // scanAndRequestMissingTextures(); never from inside lockContext. + void emitTextureRequest(const std::string& filePath); + + // Invokes the JS handler's onTextureEvict hook with the given filePaths. No-op when the + // list is empty, when the handler has not been registered, or when the handler does not + // expose this method. Called from enforceFullBudget(); never from inside lockContext. + void emitTextureEvict(const std::vector& filePaths); + float pagxWidth = 0.0f; + float pagxHeight = 0.0f; + int canvasWidth = 0; + int canvasHeight = 0; + FontConfig fontConfig = {}; + + // Background state from the PAGX document + bool backgroundVisible = false; + tgfx::Color backgroundTGFXColor = DefaultBackgroundColor(); + + // Performance monitoring + struct FrameRecord { + double timestampMs = 0.0; + double durationMs = 0.0; + }; + std::deque frameHistory = {}; + double frameHistoryTotalTime = 0.0; + bool lastFrameSlow = false; + + // Bounds origin: PAGX content bounding box top-left relative to cocraft canvas origin + float boundsOriginX = 0.0f; + float boundsOriginY = 0.0f; + bool boundsOriginOverridden = false; + + // State tracking + bool hasRenderedFirstFrame = false; + + // Accumulated decoded image accounting (debug only). Reset in parsePAGX(). Pixel total + // approximates the GPU texture footprint at RGBA8 (×4 bytes), useful for correlating wasm + // heap with image load. Currently only incremented by flushPendingUploads. + uint64_t imageDecodedPixelTotal = 0; + uint32_t imageDecodedCount = 0; + // Histogram of decoded image sizes by pixel area, indexed by bucket: 0:<10K, 1:<100K, + // 2:<500K, 3:<1M, 4:<2M, 5:>=2M. Lets us tell at a glance whether the document is dominated + // by tiny icons or by large photos without scrolling through per-image log lines. + static constexpr size_t IMAGE_SIZE_BUCKET_COUNT = 6; + uint32_t imageSizeBuckets[IMAGE_SIZE_BUCKET_COUNT] = {}; + + float lastZoom = 1.0f; + bool isZooming = false; + // Frame counter driving the throttled GPU cache footprint probe in draw(). Per-instance so + // each view's probe cadence is independent rather than shared across all PAGXView instances. + int gpuProbeCounter = 0; + // isZoomingIn is derived per-frame from a single (zoom > lastZoom) comparison and is only + // consumed by the in/out timeout split in draw() (ZOOM_IN/OUT_END_TIMEOUT_MS). The throttle + // itself is no longer driven from here -- tgfx infers direction internally. + bool isZoomingIn = false; + int currentMaxTilesRefinedPerFrame = 1; + double tryUpgradeTimestampMs = 0.0; + double lastZoomUpdateTimestampMs = 0.0; +}; + +} // namespace pagx diff --git a/pagx/wechat/src/binding.cpp b/pagx/wechat/src/binding.cpp new file mode 100644 index 0000000000..51b1ca97f4 --- /dev/null +++ b/pagx/wechat/src/binding.cpp @@ -0,0 +1,57 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#include +#include "PAGXView.h" + +using namespace emscripten; +using namespace pagx; + +// Registers PAGXView class and its public methods for JavaScript access via Emscripten bindings. +EMSCRIPTEN_BINDINGS(PAGXView) { + class_("PAGXView") + .smart_ptr>("PAGXView") + .class_function("MakeFrom", &PAGXView::MakeFrom) + .function("registerFonts", &PAGXView::registerFonts) + .function("width", &PAGXView::width) + .function("height", &PAGXView::height) + .function("loadPAGX", &PAGXView::loadPAGX) + .function("parsePAGX", &PAGXView::parsePAGX) + .function("getExternalFilePaths", &PAGXView::getExternalFilePaths) + .function("loadFileData", &PAGXView::loadFileData) + .function("attachNativeImage", &PAGXView::attachNativeImage) + .function("setTextureEventHandler", &PAGXView::setTextureEventHandler) + .function("isFullBudgetSaturated", &PAGXView::isFullBudgetSaturated) + .function("getImageBounds", &PAGXView::getImageBounds) + .function("getImageMetadata", &PAGXView::getImageMetadata) + .function("setImageOriginalSize", &PAGXView::setImageOriginalSize) + .function("buildLayers", &PAGXView::buildLayers) + .function("updateSize", &PAGXView::updateSize) + .function("updateZoomScaleAndOffset", &PAGXView::updateZoomScaleAndOffset) + .function("onZoomEnd", &PAGXView::onZoomEnd) + .function("draw", &PAGXView::draw) + .function("firstFrameRendered", &PAGXView::firstFrameRendered) + .function("resetForFreshCapture", &PAGXView::resetForFreshCapture) + .function("contentWidth", &PAGXView::contentWidth) + .function("contentHeight", &PAGXView::contentHeight) + .function("setBoundsOrigin", &PAGXView::setBoundsOrigin) + .function("setGestureActive", &PAGXView::setGestureActive) + .function("setSnapshotEnabled", &PAGXView::setSnapshotEnabled) + .function("getContentTransform", &PAGXView::getContentTransform) + .function("getNodePosition", &PAGXView::getNodePosition); +} diff --git a/pagx/wechat/src/utils/ImagePatternMatrixCalculator.cpp b/pagx/wechat/src/utils/ImagePatternMatrixCalculator.cpp new file mode 100644 index 0000000000..a9b13a3935 --- /dev/null +++ b/pagx/wechat/src/utils/ImagePatternMatrixCalculator.cpp @@ -0,0 +1,413 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#include "ImagePatternMatrixCalculator.h" +#include +#include +#include +#include + +#include "pagx/ImageResourceProvider.h" +#include "pagx/nodes/Image.h" +#include "tgfx/core/Data.h" +#include "tgfx/core/Image.h" +#include "tgfx/core/ImageCodec.h" + +namespace pagx { + +// customData key used to persist the original paint transform exported from the source file. +// ResolveImagePatternMatrix() overwrites pattern->matrix with the baked result, so we stash the +// original 6-float affine here on first call to make the whole routine idempotent: later calls +// (for example after progressive image upgrades replace the thumbnail with a higher-resolution +// full image in the provider) can re-bake against the correct source matrix instead of +// re-baking the already baked matrix. +static constexpr const char* kPaintTransformKey = "paint-transform"; + +// Same idempotency stash but for the "new PAGX standard format" path: the exporter already +// bakes pattern->matrix in original-image pixel coordinates so we cache that authored matrix +// before applying the progressive-image post-scale. Distinct key from kPaintTransformKey so a +// document that mixes legacy and new patterns (only theoretical, but cheap to support) keeps +// the two caches independent. +static constexpr const char* kAuthoredMatrixKey = "authored-pattern-matrix"; + +// Threshold for treating a 2x2 determinant as singular (non-invertible). Values below this +// produce numerically unstable inverses under float32 precision (~7 significant digits). +static constexpr float SINGULAR_DETERMINANT_EPSILON = 1e-6f; + +static std::string SerializePaintTransform(const pagx::Matrix& matrix) { + char buf[128] = {}; + std::snprintf(buf, sizeof(buf), "%.9g,%.9g,%.9g,%.9g,%.9g,%.9g", matrix.a, matrix.b, matrix.c, + matrix.d, matrix.tx, matrix.ty); + return buf; +} + +static bool DeserializePaintTransform(const std::string& text, pagx::Matrix* out) { + float values[6] = {}; + const char* cursor = text.c_str(); + for (int i = 0; i < 6; ++i) { + char* end = nullptr; + values[i] = std::strtof(cursor, &end); + if (end == cursor) { + return false; + } + cursor = end; + // Skip the separator ',' between fields; the last field has no trailing comma. + if (i < 5) { + while (*cursor == ' ' || *cursor == ',') { + ++cursor; + } + } + } + out->a = values[0]; + out->b = values[1]; + out->c = values[2]; + out->d = values[3]; + out->tx = values[4]; + out->ty = values[5]; + return true; +} + +// Stretch the image to exactly fill the node by scaling each axis independently. Callers must +// guarantee imageWidth/imageHeight are positive (the public entry point checks this upfront). +static void ApplyStretchToNode(pagx::Matrix* matrix, float imageWidth, float imageHeight, + float nodeWidth, float nodeHeight) { + matrix->a = nodeWidth / imageWidth; + matrix->d = nodeHeight / imageHeight; +} + +pagx::Matrix CalculateImagePatternMatrix(ImageScaleMode scaleMode, float imageWidth, float imageHeight, + float nodeWidth, float nodeHeight, const pagx::Matrix& paintTransform, + float scaleFactor, float origImageWidth, float origImageHeight) { + pagx::Matrix matrix = {}; + matrix.a = 1.0f; + matrix.d = 1.0f; + + if (imageWidth <= 0.0f || imageHeight <= 0.0f || nodeWidth <= 0.0f || nodeHeight <= 0.0f) { + if (!paintTransform.isIdentity()) { + return paintTransform; + } + return matrix; + } + + float imageRatio = imageWidth / imageHeight; + float nodeRatio = nodeWidth / nodeHeight; + + switch (scaleMode) { + case ImageScaleMode::FILL: { + float ratio = (imageRatio > nodeRatio) ? (nodeHeight / imageHeight) : (nodeWidth / imageWidth); + float marginX = (nodeWidth - imageWidth * ratio) * 0.5f; + float marginY = (nodeHeight - imageHeight * ratio) * 0.5f; + matrix.a = ratio; + matrix.d = ratio; + matrix.tx = marginX; + matrix.ty = marginY; + break; + } + case ImageScaleMode::FIT: { + float ratio = (nodeRatio > imageRatio) ? (nodeHeight / imageHeight) : (nodeWidth / imageWidth); + float marginX = (nodeWidth - imageWidth * ratio) * 0.5f; + float marginY = (nodeHeight - imageHeight * ratio) * 0.5f; + matrix.a = ratio; + matrix.d = ratio; + matrix.tx = marginX; + matrix.ty = marginY; + break; + } + case ImageScaleMode::STRETCH: { + // pagx::Matrix layout: | a c tx | + // | b d ty | + if (!paintTransform.isIdentity()) { + float invNW = 1.0f / nodeWidth; + float invNH = 1.0f / nodeHeight; + float s00 = paintTransform.a * invNW, s01 = paintTransform.c * invNH, s02 = paintTransform.tx; + float s10 = paintTransform.b * invNW, s11 = paintTransform.d * invNH, s12 = paintTransform.ty; + + s00 *= imageWidth; s01 *= imageWidth; s02 *= imageWidth; + s10 *= imageHeight; s11 *= imageHeight; s12 *= imageHeight; + + float det = s00 * s11 - s01 * s10; + if (std::abs(det) > SINGULAR_DETERMINANT_EPSILON) { + float invDet = 1.0f / det; + matrix.a = s11 * invDet; + matrix.c = -s01 * invDet; + matrix.b = -s10 * invDet; + matrix.d = s00 * invDet; + matrix.tx = (s01 * s12 - s11 * s02) * invDet; + matrix.ty = (s10 * s02 - s00 * s12) * invDet; + } else { + // Singular paint transform cannot be inverted; fall back to the same node/image + // proportional stretch used for the identity-paintTransform path instead of leaving + // the matrix at its identity initial value. + ApplyStretchToNode(&matrix, imageWidth, imageHeight, nodeWidth, nodeHeight); + } + } else { + ApplyStretchToNode(&matrix, imageWidth, imageHeight, nodeWidth, nodeHeight); + } + break; + } + case ImageScaleMode::TILE: { + float ratioX = scaleFactor; + float ratioY = scaleFactor; + if (origImageWidth > 0.0f) { + ratioX = scaleFactor * origImageWidth / imageWidth; + } + if (origImageHeight > 0.0f) { + ratioY = scaleFactor * origImageHeight / imageHeight; + } + matrix.a = ratioX; + matrix.d = ratioY; + break; + } + default: + break; + } + + return matrix; +} + +// Returns the actual pixel dimensions of the image currently bound to the pattern, falling +// back through the same source priority used by LayerBuilder::getOrCreateImage() so the matrix +// stays consistent with the asset that will actually sample at draw time: +// 1. Provider-resolved image (full or thumbnail from ImageResourceProvider). +// 2. data - encoded bytes; peeked via ImageCodec::MakeFrom without a full decode. +// fallbackWidth/fallbackHeight are returned untouched when none of the sources resolves +// to a positive dimension; callers pass either origImageWidth (legacy path) or 0 (new-format +// path) depending on what they want to fall back to. +static void ReadActualImageDimensions(const pagx::Image* imageNode, + pagx::ImageResourceProvider* provider, + float fallbackWidth, float fallbackHeight, + float* outWidth, float* outHeight) { + *outWidth = fallbackWidth; + *outHeight = fallbackHeight; + if (!imageNode) { + return; + } + if (provider && !imageNode->filePath.empty()) { + auto resolved = provider->resolveImage(imageNode->filePath); + if (resolved && resolved->width() > 0 && resolved->height() > 0) { + *outWidth = static_cast(resolved->width()); + *outHeight = static_cast(resolved->height()); + return; + } + } + if (imageNode->data && imageNode->data->size() > 0) { + auto tgfxData = tgfx::Data::MakeWithoutCopy(imageNode->data->data(), imageNode->data->size()); + auto codec = tgfx::ImageCodec::MakeFrom(tgfxData); + if (codec && codec->width() > 0 && codec->height() > 0) { + *outWidth = static_cast(codec->width()); + *outHeight = static_cast(codec->height()); + } + } +} + +// Resolve path for the new PAGX standard format: pattern->matrix is already authored against +// original-image pixel coordinates, so we only need to undo any scaling between origSize and +// the actually attached image's pixel size. Returns true when handled (matrix possibly +// rewritten), false to defer to the legacy customData-driven path. +// +// origSize source priority: +// 1. pattern->customData["orig-image-width"/"orig-image-height"] - written by the new +// exporter alongside the baked matrix. When present, the SDK is fully self-sufficient +// and does not need any host-side runtime registration. +// 2. origSizeMap[filePath] - registered by the host via setImageOriginalSize(), used as a +// fallback for documents exported by older tools that bake the matrix but do not write +// the orig-image-* fields into customData. +static bool ResolveNewFormatPattern(pagx::ImagePattern* pattern, + const ImageOriginalSizeMap* origSizeMap, + pagx::ImageResourceProvider* provider) { + if (!pattern->image) { + return false; + } + float origWidth = 0.0f; + float origHeight = 0.0f; + auto oiwIt = pattern->customData.find("orig-image-width"); + auto oihIt = pattern->customData.find("orig-image-height"); + if (oiwIt != pattern->customData.end() && oihIt != pattern->customData.end()) { + origWidth = std::strtof(oiwIt->second.c_str(), nullptr); + origHeight = std::strtof(oihIt->second.c_str(), nullptr); + } + if ((origWidth <= 0.0f || origHeight <= 0.0f) && origSizeMap) { + const auto& filePath = pattern->image->filePath; + if (!filePath.empty()) { + auto sizeIt = origSizeMap->find(filePath); + if (sizeIt != origSizeMap->end()) { + origWidth = sizeIt->second.first; + origHeight = sizeIt->second.second; + } + } + } + if (origWidth <= 0.0f || origHeight <= 0.0f) { + return false; + } + + // Cache the authored matrix on first call so re-resolves (post thumbnail->full upgrade) start + // from the original authored matrix rather than re-scaling an already-scaled result. + pagx::Matrix authoredMatrix = {}; + auto cacheIt = pattern->customData.find(kAuthoredMatrixKey); + if (cacheIt != pattern->customData.end() && + DeserializePaintTransform(cacheIt->second, &authoredMatrix)) { + // Already cached; pattern->matrix may hold a previously baked variant, so use the cache. + } else { + authoredMatrix = pattern->matrix; + pattern->customData[kAuthoredMatrixKey] = SerializePaintTransform(authoredMatrix); + } + + float actualWidth = 0.0f; + float actualHeight = 0.0f; + ReadActualImageDimensions(pattern->image, provider, origWidth, origHeight, &actualWidth, + &actualHeight); + + if (actualWidth <= 0.0f || actualHeight <= 0.0f || + (actualWidth == origWidth && actualHeight == origHeight)) { + // No usable actual size, or attached image already matches origSize. Either way the + // authored matrix is correct as-is. + pattern->matrix = authoredMatrix; + return true; + } + + // pattern->matrix maps points expressed in original-image pixels to the geometry's local + // space. The actually attached texture has actualSize pixels along each axis but the same + // visible content area, so we pre-multiply the matrix by diag(origW/actualW, origH/actualH). + // After the post-scale, mapping a point p_actual in the attached texture's pixel space goes: + // p_geom = authoredMatrix * diag(origW/actualW, origH/actualH) * p_actual + // which equals what the exporter intended (authoredMatrix applied to the equivalent original- + // image-pixel coordinate). + pagx::Matrix scale = {}; + scale.a = origWidth / actualWidth; + scale.d = origHeight / actualHeight; + + pattern->matrix = authoredMatrix * scale; + return true; +} + +bool ResolveImagePatternMatrix(pagx::ImagePattern* pattern, + const ImageOriginalSizeMap* origSizeMap, + pagx::ImageResourceProvider* provider) { + if (!pattern || !pattern->image) { + return false; + } + + auto scaleModeIt = pattern->customData.find("image-scale-mode"); + if (scaleModeIt == pattern->customData.end()) { + // No legacy customData: try the new PAGX standard format. When that path is also unable to + // produce a meaningful result (e.g. origSize hasn't been registered yet), leave the + // authored matrix unchanged and report unresolved. + return ResolveNewFormatPattern(pattern, origSizeMap, provider); + } + + char* end = nullptr; + int scaleModeInt = static_cast(std::strtol(scaleModeIt->second.c_str(), &end, 10)); + if (end == scaleModeIt->second.c_str()) { + return false; + } + auto scaleMode = static_cast(scaleModeInt); + + float nodeWidth = 0.0f, nodeHeight = 0.0f; + auto nwIt = pattern->customData.find("node-width"); + auto nhIt = pattern->customData.find("node-height"); + if (nwIt != pattern->customData.end()) { + nodeWidth = std::strtof(nwIt->second.c_str(), nullptr); + } + if (nhIt != pattern->customData.end()) { + nodeHeight = std::strtof(nhIt->second.c_str(), nullptr); + } + + float origImageWidth = 0.0f, origImageHeight = 0.0f; + auto oiwIt = pattern->customData.find("orig-image-width"); + auto oihIt = pattern->customData.find("orig-image-height"); + if (oiwIt != pattern->customData.end()) { + origImageWidth = std::strtof(oiwIt->second.c_str(), nullptr); + } + if (oihIt != pattern->customData.end()) { + origImageHeight = std::strtof(oihIt->second.c_str(), nullptr); + } + + float scaleFactor = 0.5f; + auto sfIt = pattern->customData.find("scale-factor"); + if (sfIt != pattern->customData.end()) { + scaleFactor = std::strtof(sfIt->second.c_str(), nullptr); + } + + // Parse actual image dimensions from the best available source; fall back to origImage* when + // no decoded asset has arrived yet (placeholder layout during progressive loading). + float actualImageWidth = 0.0f; + float actualImageHeight = 0.0f; + ReadActualImageDimensions(pattern->image, provider, origImageWidth, origImageHeight, + &actualImageWidth, &actualImageHeight); + + // The matrix stored during export is paint->transform() (normalized transform). After the + // first successful resolve, pattern->matrix is overwritten with the baked result, so we keep + // the original paint transform in customData to stay idempotent across re-resolves triggered + // by progressive image upgrades. + pagx::Matrix paintTransform = {}; + auto ptIt = pattern->customData.find(kPaintTransformKey); + if (ptIt != pattern->customData.end() && DeserializePaintTransform(ptIt->second, &paintTransform)) { + // Already cached; use the original paint transform and ignore pattern->matrix, which holds + // a previously baked result. + } else { + paintTransform = pattern->matrix; + pattern->customData[kPaintTransformKey] = SerializePaintTransform(paintTransform); + } + + pattern->matrix = CalculateImagePatternMatrix( + scaleMode, actualImageWidth, actualImageHeight, + nodeWidth, nodeHeight, paintTransform, scaleFactor, + origImageWidth, origImageHeight); + + return true; +} + +void ResolveAllImagePatternMatrices(pagx::PAGXDocument* document, + const ImageOriginalSizeMap* origSizeMap, + pagx::ImageResourceProvider* provider) { + if (!document) { + return; + } + for (const auto& nodePtr : document->nodes) { + if (!nodePtr || nodePtr->nodeType() != pagx::NodeType::ImagePattern) { + continue; + } + auto* pattern = static_cast(nodePtr.get()); + ResolveImagePatternMatrix(pattern, origSizeMap, provider); + } +} + +size_t ResolveImagePatternMatricesByFilePath(pagx::PAGXDocument* document, + const std::string& filePath, + const ImageOriginalSizeMap* origSizeMap, + pagx::ImageResourceProvider* provider) { + if (!document || filePath.empty()) { + return 0; + } + size_t updated = 0; + for (const auto& nodePtr : document->nodes) { + if (!nodePtr || nodePtr->nodeType() != pagx::NodeType::ImagePattern) { + continue; + } + auto* pattern = static_cast(nodePtr.get()); + if (!pattern->image || pattern->image->filePath != filePath) { + continue; + } + if (ResolveImagePatternMatrix(pattern, origSizeMap, provider)) { + ++updated; + } + } + return updated; +} + +} // namespace pagx diff --git a/pagx/wechat/src/utils/ImagePatternMatrixCalculator.h b/pagx/wechat/src/utils/ImagePatternMatrixCalculator.h new file mode 100644 index 0000000000..a9bd4a1a9a --- /dev/null +++ b/pagx/wechat/src/utils/ImagePatternMatrixCalculator.h @@ -0,0 +1,96 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include +#include +#include +#include + +#include "pagx/PAGXDocument.h" +#include "pagx/nodes/ImagePattern.h" +#include "pagx/types/Matrix.h" + +namespace pagx { + +// Image scale modes corresponding to CoCraft Schema::ImageScaleMode enum values. +enum class ImageScaleMode : int { + STRETCH = 0, + FIT = 1, + FILL = 2, + TILE = 3, +}; + +// Calculates the ImagePattern transform matrix based on the image scale mode. +// origImageWidth/origImageHeight are used only by the TILE branch to compensate progressive +// image loading: they represent the full-resolution pixel dimensions recorded by the exporter +// so the tile period stays stable while imageWidth/imageHeight grow from placeholder toward +// full resolution. Pass 0 for both to disable compensation and fall back to cocraft's +// ratio = scaleFactor behavior. +pagx::Matrix CalculateImagePatternMatrix(ImageScaleMode scaleMode, float imageWidth, float imageHeight, + float nodeWidth, float nodeHeight, const pagx::Matrix& paintTransform, + float scaleFactor = 0.5f, + float origImageWidth = 0.0f, float origImageHeight = 0.0f); + +// Per-image original-pixel-size lookup used by the new-format resolve path. Maps the Image +// node's filePath ("hash:..." / "emoji:...") to the full-resolution pixel dimensions that the +// exporter assumed when baking pattern->matrix. The table is owned by PAGXView and populated +// via PAGXView::setImageOriginalSize() before attachNativeImage(); ResolveImagePatternMatrix() +// reads it to undo any scaling applied by progressive thumbnail/full downloads. May be null +// to skip the new-format path entirely (legacy customData-only flow). +using ImageOriginalSizeMap = std::unordered_map>; + +// Resolves a single ImagePattern's transform matrix from its customData and actual image +// dimensions. Idempotent: the original paint transform / matrix is cached in customData on +// first call, so repeated invocations after provider state changes recompute against the +// current image size rather than re-baking the previously baked matrix. +// +// Two formats are supported: +// 1. Legacy CoCraft format (customData carries image-scale-mode, node-width/height, +// orig-image-width/height, scale-factor): rebuilt via CalculateImagePatternMatrix() against +// the current image's pixel dimensions. +// 2. New PAGX standard format (no image-scale-mode in customData): the exporter has already +// baked pattern->matrix in original-image pixel coordinates. When the actually attached +// image has different pixel dimensions (progressive thumbnail / CDN-shrunk variant), the +// matrix is post-scaled by diag(origW/actualW, origH/actualH) so tgfx's getFitMatrix / +// shader sampling sees the same visual layout regardless of the asset's actual resolution. +// origW/origH source priority: first read from pattern->customData +// ("orig-image-width"/"orig-image-height", written by the new exporter); if missing, +// fall back to origSizeMap[filePath] populated by the host via setImageOriginalSize(); +// if both are unavailable the authored matrix is left unchanged. +bool ResolveImagePatternMatrix(pagx::ImagePattern* pattern, + const ImageOriginalSizeMap* origSizeMap = nullptr, + pagx::ImageResourceProvider* provider = nullptr); + +// Resolves all ImagePattern transform matrices in the document. Should be called after loading +// external image data and before LayerBuilder::Build(). +void ResolveAllImagePatternMatrices(pagx::PAGXDocument* document, + const ImageOriginalSizeMap* origSizeMap = nullptr, + pagx::ImageResourceProvider* provider = nullptr); + +// Resolves ImagePattern transform matrices for every pattern whose backing Image node has the +// given filePath. Returns the number of patterns whose matrix was refreshed. Intended for the +// progressive image upgrade path: when a higher-resolution image replaces the initial one, the +// baked pattern matrix must be recomputed against the new image dimensions. +size_t ResolveImagePatternMatricesByFilePath(pagx::PAGXDocument* document, + const std::string& filePath, + const ImageOriginalSizeMap* origSizeMap = nullptr, + pagx::ImageResourceProvider* provider = nullptr); + +} // namespace pagx diff --git a/pagx/wechat/src/utils/StringParser.h b/pagx/wechat/src/utils/StringParser.h new file mode 100644 index 0000000000..a3b07fb3be --- /dev/null +++ b/pagx/wechat/src/utils/StringParser.h @@ -0,0 +1,89 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include +#include "tgfx/core/Color.h" + +namespace pagx { + +inline int ParseHexDigit(char ch) { + if (ch >= '0' && ch <= '9') { + return ch - '0'; + } + if (ch >= 'a' && ch <= 'f') { + return ch - 'a' + 10; + } + if (ch >= 'A' && ch <= 'F') { + return ch - 'A' + 10; + } + return -1; +} + +inline tgfx::Color DefaultBackgroundColor() { + return tgfx::Color::FromRGBA(245, 245, 245); +} + +inline tgfx::Color ParseHexColor(const std::string& hex) { + if (hex.empty() || hex[0] != '#') { + return DefaultBackgroundColor(); + } + if (hex.size() == 4 || hex.size() == 5) { + int r = ParseHexDigit(hex[1]); + int g = ParseHexDigit(hex[2]); + int b = ParseHexDigit(hex[3]); + if (r < 0 || g < 0 || b < 0) { + return DefaultBackgroundColor(); + } + if (hex.size() == 5) { + int a = ParseHexDigit(hex[4]); + if (a < 0) { + return DefaultBackgroundColor(); + } + return tgfx::Color::FromRGBA(r * 17, g * 17, b * 17, a * 17); + } + return tgfx::Color::FromRGBA(r * 17, g * 17, b * 17); + } + if (hex.size() == 7 || hex.size() == 9) { + int r1 = ParseHexDigit(hex[1]); + int r2 = ParseHexDigit(hex[2]); + int g1 = ParseHexDigit(hex[3]); + int g2 = ParseHexDigit(hex[4]); + int b1 = ParseHexDigit(hex[5]); + int b2 = ParseHexDigit(hex[6]); + if (r1 < 0 || r2 < 0 || g1 < 0 || g2 < 0 || b1 < 0 || b2 < 0) { + return DefaultBackgroundColor(); + } + int r = r1 * 16 + r2; + int g = g1 * 16 + g2; + int b = b1 * 16 + b2; + if (hex.size() == 9) { + int a1 = ParseHexDigit(hex[7]); + int a2 = ParseHexDigit(hex[8]); + if (a1 < 0 || a2 < 0) { + return DefaultBackgroundColor(); + } + return tgfx::Color::FromRGBA(r, g, b, a1 * 16 + a2); + } + return tgfx::Color::FromRGBA(r, g, b); + } + return DefaultBackgroundColor(); +} + +} // namespace pagx diff --git a/pagx/wechat/ts/babel.ts b/pagx/wechat/ts/babel.ts new file mode 100644 index 0000000000..cfb681bc5d --- /dev/null +++ b/pagx/wechat/ts/babel.ts @@ -0,0 +1,33 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +/* global globalThis */ +// Polyfills for WeChat Mini Program environment: registers WXWebAssembly as WebAssembly +// and ensures globalThis/window are available for Emscripten module initialization. + +declare const WXWebAssembly: typeof WebAssembly; +declare const globalThis: any; + +globalThis.WebAssembly = WXWebAssembly; +globalThis.isWxWebAssembly = true; +// eslint-disable-next-line no-global-assign +window = globalThis; + +// Keep this file a module so the ambient `declare const globalThis` above stays module-scoped; +// without an import/export it becomes a global script and clashes with the built-in globalThis. +export {}; diff --git a/pagx/wechat/ts/backend-context.ts b/pagx/wechat/ts/backend-context.ts new file mode 100644 index 0000000000..65b1fd36cf --- /dev/null +++ b/pagx/wechat/ts/backend-context.ts @@ -0,0 +1,99 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +import type { PAGX } from './types'; + +const WEBGL_CONTEXT_ATTRIBUTES = { + depth: false, + stencil: false, + antialias: false, +}; + +/** + * BackendContext wraps Emscripten GL context handle and manages context switching. + */ +export class BackendContext { + public handle: number; + private externallyOwned: boolean; + private oldHandle: number = 0; + + /** + * Create BackendContext from WebGL2RenderingContext. + * Registers the context to Emscripten if not already registered. + */ + public static from(module: PAGX, gl: WebGL2RenderingContext): BackendContext { + const { GL } = module; + let id = -1; + + // Check if context is already registered + if (GL.contexts.length > 0) { + id = GL.contexts.findIndex((context: any) => context?.GLctx === gl); + } + + if (id < 0) { + // Register new context to Emscripten (WebGL 2 only) + id = GL.registerContext(gl, { + majorVersion: 2, + minorVersion: 0, + ...WEBGL_CONTEXT_ATTRIBUTES, + }); + return new BackendContext(id, false); + } + return new BackendContext(id, true); + } + + private constructor(handle: number, externallyOwned: boolean = false) { + this.handle = handle; + this.externallyOwned = externallyOwned; + } + + /** + * Make this context current for the calling thread. + * Returns true if successful. + */ + public makeCurrent(module: PAGX): boolean { + this.oldHandle = module.GL.currentContext?.handle || 0; + if (this.oldHandle === this.handle) { + return true; + } + return module.GL.makeContextCurrent(this.handle); + } + + /** + * Restore the previous context. + */ + public clearCurrent(module: PAGX): void { + if (this.oldHandle === this.handle) { + return; + } + module.GL.makeContextCurrent(0); + if (this.oldHandle) { + module.GL.makeContextCurrent(this.oldHandle); + } + } + + /** + * Destroy the context if it's not externally owned. + */ + public destroy(module: PAGX): void { + if (this.externallyOwned) { + return; + } + module.GL.deleteContext(this.handle); + } +} diff --git a/pagx/wechat/ts/backend-texture.ts b/pagx/wechat/ts/backend-texture.ts new file mode 100644 index 0000000000..d0101cfdbb --- /dev/null +++ b/pagx/wechat/ts/backend-texture.ts @@ -0,0 +1,170 @@ +/////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +/////////////////////////////////////////////////////////////////////////////////////////////// + +import type { EmscriptenGL } from '@tgfx/types'; + +type WxOffscreenCanvas = OffscreenCanvas & { isOffscreenCanvas?: boolean }; + +/** + * Creates a new GL texture from a host-decoded image source, registers it with emscripten's + * GL.textures table, uploads pixels via texImage2D, and generates a full mipmap chain. Returns + * the GL.textures index of the newly created texture (a positive integer); the wasm side wraps + * it in a tgfx::BackendTexture and creates a tgfx::Image::MakeAdopted so the tgfx-managed + * lifetime drives glDeleteTextures when the image is no longer referenced. + * + * Mipmaps are always generated. Both thumbnail (typically displayed at fit-zoom which renders + * the source at <1x scale) and full (rendered <1x at fit and >1x only when the user zooms in + * beyond the original DPI) benefit from a precomputed chain — the cost is a fixed 1/3 byte + * overhead per texture, far cheaper than re-decoding the source on every zoom level change. + * + * Texture parameters: TEXTURE_2D, RGBA, UNSIGNED_BYTE, LINEAR_MIPMAP_LINEAR for minification, + * LINEAR for magnification, CLAMP_TO_EDGE on both axes (image-pattern tiling is handled + * separately by the renderer's sampler state, not by the texture itself). + * + * @param GL The emscripten GL bridge object (typically obtained from `module.GL`). + * @param source The image source. Must be one of: + * - WeChat OffscreenCanvas: detected via the `isOffscreenCanvas` discriminator. WeChat's + * 2D OffscreenCanvas cannot be passed directly to texImage2D as TexImageSource on iOS; + * we read pixels through getImageData and upload as a Uint8Array buffer instead. + * - Standard web TexImageSource (HTMLImageElement, ImageBitmap, browser OffscreenCanvas, + * HTMLCanvasElement, HTMLVideoElement): handed straight to texImage2D. + * @returns The GL.textures index on success (always > 0), or 0 on failure (no current GL + * context, or the source has zero width/height after decoding). + */ +export const createBackendTexture = ( + GL: EmscriptenGL, + source: TexImageSource | WxOffscreenCanvas, +): number => { + const ctx = GL.currentContext; + if (!ctx) { + return 0; + } + const gl = ctx.GLctx as WebGL2RenderingContext; + const width = (source as { width?: number }).width || 0; + const height = (source as { height?: number }).height || 0; + if (width < 1 || height < 1) { + return 0; + } + + const tex = gl.createTexture(); + if (!tex) { + return 0; + } + // emscripten requires textures used by GLES code to be registered in GL.textures so the wasm + // side can refer to them by integer id. The .name property mirrors what + // GL.createTexture/_glGenTextures would have set. + const id = GL.getNewId(GL.textures); + (tex as any).name = id; + GL.textures[id] = tex; + + gl.bindTexture(gl.TEXTURE_2D, tex); + gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true); + + // Shared failure cleanup: any error path after the premultiply flag was raised must reset it + // before returning, otherwise the sticky UNPACK_PREMULTIPLY_ALPHA_WEBGL state corrupts later + // non-premultiplied uploads driven by other code paths (e.g. tgfx.uploadToTexture). Also + // deletes the texture and clears its GL.textures slot so the id is not leaked. + const cleanupFailedTexture = (): number => { + gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false); + gl.deleteTexture(tex); + GL.textures[id] = null; + return 0; + }; + + const wxCanvas = source as WxOffscreenCanvas; + if (wxCanvas.isOffscreenCanvas) { + // WeChat's 2d OffscreenCanvas cannot be used as a TexImageSource directly on iOS. Read + // back via getImageData; the resulting Uint8ClampedArray is wrapped (no copy) into a + // Uint8Array view that texImage2D accepts. If the 2d context is unavailable or the + // read-back throws, delete and unregister the texture so its GL.textures id is not leaked. + const canvasCtx = wxCanvas.getContext('2d') as OffscreenCanvasRenderingContext2D | null; + if (!canvasCtx) { + return cleanupFailedTexture(); + } + try { + const imageData = canvasCtx.getImageData(0, 0, width, height); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA, + width, + height, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + new Uint8Array(imageData.data.buffer, imageData.data.byteOffset, imageData.data.byteLength), + ); + } catch (e) { + return cleanupFailedTexture(); + } + } else { + // Mirror the wxCanvas branch: a malformed or oversized source can make texImage2D throw, + // which would otherwise leak both the GL.textures slot and the raised premultiply flag. + try { + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source as TexImageSource); + } catch (e) { + return cleanupFailedTexture(); + } + } + + gl.generateMipmap(gl.TEXTURE_2D); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + // Reset the premultiply flag so subsequent uploads driven by other code paths (e.g. + // tgfx.uploadToTexture) start from the documented default. + gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false); + return id; +}; + +/** + * Releases a batch of GL textures previously created by createBackendTexture(). The wasm side + * accumulates retired GL.textures indices in a single std::vector and hands the entire batch + * across the boundary in one call so wasm↔JS overhead stays at O(1) per draw regardless of + * how many textures were evicted. For each id this removes the GL.textures entry and asks the + * driver to free the associated GPU memory; in-flight commands that already reference the + * texture continue to execute correctly per the WebGL/GLES specification. + * + * @param GL The emscripten GL bridge object (typically obtained from `module.GL`). + * @param textureIds A plain JavaScript Array of GL.textures indices to release. Entries equal + * to 0 or pointing at slots that are already empty are silently skipped so the wasm side + * never has to special-case partial-failure paths. + */ +export const destroyBackendTextures = (GL: EmscriptenGL, textureIds: number[]): void => { + const ctx = GL.currentContext; + if (!ctx) { + return; + } + const gl = ctx.GLctx as WebGL2RenderingContext; + for (let i = 0; i < textureIds.length; i++) { + const id = textureIds[i]; + if (!id) { + continue; + } + const tex = GL.textures[id]; + if (!tex) { + continue; + } + gl.deleteTexture(tex); + // Match the pattern emscripten uses when synthesising _glDeleteTextures: clear both the + // .name property and the GL.textures slot so a recycled id does not point at a deleted + // WebGLTexture object. + GL.textures[id] = null; + } +}; diff --git a/pagx/wechat/ts/binding.ts b/pagx/wechat/ts/binding.ts new file mode 100644 index 0000000000..326b2a8ecf --- /dev/null +++ b/pagx/wechat/ts/binding.ts @@ -0,0 +1,41 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +import { PAGX } from './types'; +import { View } from './pagx-view'; +import { createBackendTexture, destroyBackendTextures } from './backend-texture'; +import { TGFXBind } from '@tgfx/wechat/binding'; +import type { EmscriptenGL, WindowColorSpace } from '@tgfx/types'; + + +/** + * Binding pag js module on pag webassembly module. + */ +export const binding = (module: PAGX) => { + TGFXBind(module); + module.View = View; + // Augment the tgfx adapter namespace with PAGX-specific GL helpers so the C++ side can + // resolve them via val::module_property("tgfx").call("createBackendTexture", ...). The + // helpers themselves are PAGX-specific (mipmap-always semantics, WeChat OffscreenCanvas + // readback workaround, batched deletion driven by per-frame eviction) and intentionally + // live outside third_party/tgfx. + if (module.tgfx) { + (module.tgfx as any).createBackendTexture = createBackendTexture; + (module.tgfx as any).destroyBackendTextures = destroyBackendTextures; + } +}; diff --git a/pagx/wechat/ts/decorators.ts b/pagx/wechat/ts/decorators.ts new file mode 100644 index 0000000000..e56aaf9bc9 --- /dev/null +++ b/pagx/wechat/ts/decorators.ts @@ -0,0 +1,42 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * Class decorator that wraps all prototype methods with a destroy check. + * Methods called on a destroyed instance will log an error and return early. + * The target class must have an `isDestroyed: boolean` instance property. + */ +// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type +export function destroyVerify(constructor: Function): void { + const proto = constructor.prototype as Record; + const functions = Object.getOwnPropertyNames(proto).filter( + (name) => name !== 'constructor' && typeof proto[name] === 'function', + ); + + const proxyFn = (target: Record, methodName: string) => { + const fn = target[methodName] as (...args: unknown[]) => unknown; + target[methodName] = function (this: { isDestroyed: boolean }, ...args: unknown[]): unknown { + if (this.isDestroyed) { + console.error(`Don't call ${methodName} of the ${constructor.name} that is destroyed.`); + return undefined; + } + return fn.call(this, ...args); + }; + }; + functions.forEach((name) => proxyFn(proto, name)); +} diff --git a/pagx/wechat/ts/gesture-manager.ts b/pagx/wechat/ts/gesture-manager.ts new file mode 100644 index 0000000000..7fd54b144d --- /dev/null +++ b/pagx/wechat/ts/gesture-manager.ts @@ -0,0 +1,394 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +const TAP_TIMEOUT = 300; +const TAP_DISTANCE_THRESHOLD = 50; +const ZOOM_STABLE_THRESHOLD_MS = 200; + +export interface GestureState { + action: 'none' | 'update' | 'reset' | 'pan' | 'zoom' | 'zoomEnd'; + zoom: number; + offsetX: number; + offsetY: number; + isZooming?: boolean; +} + +type EventListener = (data: GestureState) => void; + +export class WXGestureManager { + // Current state + private zoom: number = 1.0; + private offsetX: number = 0; // Physical pixel offset passed to C++ DisplayList + private offsetY: number = 0; + + // Canvas dimensions in physical pixels + private canvasWidth: number = 0; // physical pixels (rect.width * dpr) + private canvasHeight: number = 0; // physical pixels (rect.height * dpr) + + // Single-finger drag state + private lastTouchX: number = 0; + private lastTouchY: number = 0; + + // Pinch zoom state + private initialDistance: number = 0; + private initialZoom: number = 1.0; + + // Double-tap detection + private lastTapTime: number = 0; + private lastTapX: number = 0; + private lastTapY: number = 0; + + // Zoom stability tracking + private lastZoomChangeTime: number = 0; + private zoomStableCheckTimer: number | null = null; + private isZooming: boolean = false; + private isDestroyed: boolean = false; + + // Event listeners + private listeners: Record = {}; + + /** + * Initialize gesture manager with canvas dimensions. + * Must be called after loading PAGX file. + * + * NOTE: This method resets all transforms (zoom and offsets) to initial state. + * Returns the reset state that should be applied to the view. + * + * @param canvasWidth - Canvas width in physical pixels (rect.width * dpr) + * @param canvasHeight - Canvas height in physical pixels (rect.height * dpr) + */ + public init( + canvasWidth: number, + canvasHeight: number + ): GestureState | null { + if (canvasWidth <= 0 || canvasHeight <= 0) { + return null; + } + + this.canvasWidth = canvasWidth; + this.canvasHeight = canvasHeight; + + // Reset all transforms when (re)initializing + this.zoom = 1.0; + this.offsetX = 0; + this.offsetY = 0; + + // Reset zoom state + this.isZooming = false; + this.lastZoomChangeTime = 0; + + return { + action: 'reset', + zoom: this.zoom, + offsetX: this.offsetX, + offsetY: this.offsetY, + isZooming: false + }; + } + + /** + * Update canvas size (e.g., after orientation change or window resize). + * + * @param canvasWidth - Canvas width in physical pixels (rect.width * dpr) + * @param canvasHeight - Canvas height in physical pixels (rect.height * dpr) + */ + public updateSize( + canvasWidth: number, + canvasHeight: number + ): void { + // Reject non-positive sizes (mirrors init's validation) so a bad canvas size cannot + // silently corrupt later zoom-around-point math. + if (canvasWidth <= 0 || canvasHeight <= 0) { + return; + } + this.canvasWidth = canvasWidth; + this.canvasHeight = canvasHeight; + } + + /** + * Get current gesture state. + */ + public getState(): GestureState { + return { + action: 'none', + zoom: this.zoom, + offsetX: this.offsetX, + offsetY: this.offsetY, + isZooming: this.isZooming + }; + } + + /** + * Reset to initial centered state. + */ + public reset(): GestureState { + this.zoom = 1.0; + this.offsetX = 0; + this.offsetY = 0; + + // Clear zoom state + this.isZooming = false; + this.lastZoomChangeTime = 0; + this.clearZoomStableTimer(); + + return { + action: 'reset', + zoom: this.zoom, + offsetX: this.offsetX, + offsetY: this.offsetY, + isZooming: false + }; + } + + /** + * Handle touch start event. + * @param touches - Touch array from WeChat event (coordinates in logical pixels) + */ + public onTouchStart(touches: any[]): GestureState { + if (touches.length === 1) { + // Single finger: prepare for drag + this.lastTouchX = touches[0].x; + this.lastTouchY = touches[0].y; + + // Double-tap detection (in logical pixels) + const now = Date.now(); + const distance = Math.hypot( + touches[0].x - this.lastTapX, + touches[0].y - this.lastTapY + ); + + if (now - this.lastTapTime < TAP_TIMEOUT && + distance < TAP_DISTANCE_THRESHOLD) { + this.lastTapTime = 0; + return this.reset(); + } + + this.lastTapX = touches[0].x; + this.lastTapY = touches[0].y; + this.lastTapTime = now; + + } else if (touches.length === 2) { + // Two fingers: prepare for pinch zoom + const dx = touches[1].x - touches[0].x; + const dy = touches[1].y - touches[0].y; + this.initialDistance = Math.hypot(dx, dy); + this.initialZoom = this.zoom; + + // Mark zoom started + if (!this.isZooming) { + this.isZooming = true; + this.emit('zoomStart', this.getState()); + } + } + + return this.getState(); + } + + /** + * Handle touch move event. + * @param touches - Touch array from WeChat event (coordinates in logical pixels) + * @param dpr - Device pixel ratio for converting to physical pixels + */ + public onTouchMove(touches: any[], dpr: number): GestureState { + if (touches.length === 1) { + // Single-finger drag + // Guard against finger count transition: if we just went from 2+ fingers to 1, + // reset lastTouch to current position to avoid delta spike + if (this.initialDistance > 0) { + // We were in pinch mode, now transitioning to drag + this.lastTouchX = touches[0].x; + this.lastTouchY = touches[0].y; + this.initialDistance = 0; + return this.getState(); + } + + // Convert logical pixel delta to physical pixel delta + const deltaX = (touches[0].x - this.lastTouchX) * dpr; + const deltaY = (touches[0].y - this.lastTouchY) * dpr; + + // Accumulate offset directly in physical pixels + this.offsetX += deltaX; + this.offsetY += deltaY; + + this.lastTouchX = touches[0].x; + this.lastTouchY = touches[0].y; + + return { + action: 'pan', + zoom: this.zoom, + offsetX: this.offsetX, + offsetY: this.offsetY, + isZooming: false + }; + + } else if (touches.length === 2) { + // Pinch zoom + // Ensure initialized (guard against gesture transition) + if (this.initialDistance <= 0) { + return this.getState(); + } + + const dx = touches[1].x - touches[0].x; + const dy = touches[1].y - touches[0].y; + const currentDistance = Math.hypot(dx, dy); + + const scaleChange = currentDistance / this.initialDistance; + const oldZoom = this.zoom; + const newZoom = this.initialZoom * scaleChange; + + // Calculate current pinch center (may have moved during zoom) + const currentPinchCenterX = (touches[0].x + touches[1].x) * 0.5 * dpr; + const currentPinchCenterY = (touches[0].y + touches[1].y) * 0.5 * dpr; + + // Standard zoom-around-point formula using CURRENT zoom (not initial): + // This allows for smooth continuous zooming even if fingers move + // offset_new = (offset_old - focal) * (zoom_new / zoom_old) + focal + this.offsetX = (this.offsetX - currentPinchCenterX) * (newZoom / this.zoom) + currentPinchCenterX; + this.offsetY = (this.offsetY - currentPinchCenterY) * (newZoom / this.zoom) + currentPinchCenterY; + this.zoom = newZoom; + + // Track zoom change and setup stability check + if (Math.abs(newZoom - oldZoom) > 0.001) { + this.lastZoomChangeTime = Date.now(); + + // Clear existing timer + this.clearZoomStableTimer(); + + // Start new timer to check stability + this.zoomStableCheckTimer = setTimeout(() => { + if (this.isDestroyed) return; + this.checkZoomStability(); + }, ZOOM_STABLE_THRESHOLD_MS) as any; + } + + return { + action: 'zoom', + zoom: this.zoom, + offsetX: this.offsetX, + offsetY: this.offsetY, + isZooming: true + }; + } + + return this.getState(); + } + + /** + * Handle touch end event. + * @param remainingTouches - Array of touches that are still on screen (e.touches, not e.changedTouches) + */ + public onTouchEnd(remainingTouches: any[]): GestureState { + // Handle zoom end on finger lift + if (remainingTouches.length < 2 && this.isZooming) { + this.clearZoomStableTimer(); + this.isZooming = false; + this.lastZoomChangeTime = 0; + + // Emit zoomEnd event + this.emit('zoomEnd', { + action: 'zoomEnd', + zoom: this.zoom, + offsetX: this.offsetX, + offsetY: this.offsetY, + isZooming: false + }); + } + + // Reset drag state if all fingers lifted + if (remainingTouches.length === 0) { + this.lastTouchX = 0; + this.lastTouchY = 0; + this.initialDistance = 0; + } + return this.getState(); + } + + /** + * Check if zoom has stabilized. + */ + private checkZoomStability(): void { + const now = Date.now(); + const timeSinceLastChange = now - this.lastZoomChangeTime; + + if (timeSinceLastChange >= ZOOM_STABLE_THRESHOLD_MS && this.isZooming) { + this.isZooming = false; + + this.emit('zoomEnd', { + action: 'zoomEnd', + zoom: this.zoom, + offsetX: this.offsetX, + offsetY: this.offsetY, + isZooming: false + }); + } + } + + /** + * Clear stability check timer. + */ + private clearZoomStableTimer(): void { + if (this.zoomStableCheckTimer !== null) { + clearTimeout(this.zoomStableCheckTimer); + this.zoomStableCheckTimer = null; + } + } + + /** + * Register event listener. + */ + public on(event: string, callback: EventListener): void { + if (!this.listeners[event]) { + this.listeners[event] = []; + } + this.listeners[event].push(callback); + } + + /** + * Unregister event listener. + */ + public off(event: string, callback: EventListener): void { + if (!this.listeners[event]) return; + const index = this.listeners[event].indexOf(callback); + if (index > -1) { + this.listeners[event].splice(index, 1); + } + } + + /** + * Emit event to all listeners. + */ + private emit(event: string, data: GestureState): void { + if (!this.listeners[event]) return; + this.listeners[event].forEach(callback => { + try { + callback(data); + } catch (error) { + // Silently ignore errors + } + }); + } + + /** + * Cleanup resources and remove all listeners. + */ + public destroy(): void { + this.isDestroyed = true; + this.clearZoomStableTimer(); + this.listeners = {}; + } +} diff --git a/pagx/wechat/ts/interfaces.ts b/pagx/wechat/ts/interfaces.ts new file mode 100644 index 0000000000..2e1a0e0946 --- /dev/null +++ b/pagx/wechat/ts/interfaces.ts @@ -0,0 +1,99 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +export interface wx { + env: { + USER_DATA_PATH: string; + }; + getFileSystemManager: () => FileSystemManager; + getFileInfo: (object: { filePath: string; success?: () => void; fail?: () => void; complete?: () => void }) => void; + createVideoDecoder: () => VideoDecoder; + getSystemInfoSync: () => SystemInfo; + createOffscreenCanvas: ( + object?: { type: 'webgl' | '2d' }, + width?: number, + height?: number, + compInst?: any, + ) => OffscreenCanvas; + getPerformance: () => Performance; +} + +export interface FileSystemManager { + accessSync: (path: string) => void; + mkdirSync: (path: string) => void; + writeFileSync: (path: string, data: string | ArrayBuffer, encoding?: string) => void; + unlinkSync: (path: string) => void; + readdirSync: (path: string) => string[]; +} + +export interface VideoDecoder { + getFrameData: () => FrameDataOptions; + seek: ( + /** Seek position in milliseconds. */ + position: number, + ) => Promise; + start: (option: VideoDecoderStartOption) => Promise; + remove: () => Promise; + off: ( + /** Event name. */ + eventName: string, + /** Callback function triggered when the event fires. */ + callback: (...args: any[]) => any, + ) => void; + on: ( + /** Event name. + * + * Possible values for eventName: + * - 'start': Start event. Returns \{ width, height \}. + * - 'stop': Stop event. + * - 'seek': Seek completion event. + * - 'bufferchange': Buffer change event. + * - 'ended': Decoding ended event. */ + eventName: 'start' | 'stop' | 'seek' | 'bufferchange' | 'ended', + /** Callback function triggered when the event fires. */ + callback: (...args: any[]) => any, + ) => void; +} + +/** Video frame data. Returns null if unavailable. May pause when the buffer is empty. */ +export interface FrameDataOptions { + /** Frame data. */ + data: ArrayBuffer; + /** Frame data height. */ + height: number; + /** Original DTS of the frame. */ + pkDts: number; + /** Original PTS of the frame. */ + pkPts: number; + /** Frame data width. */ + width: number; +} + +export interface VideoDecoderStartOption { + /** Video source file to decode. Versions below 2.13.0 only support local paths. From 2.13.0 onwards, http:// and https:// remote paths are also supported. */ + source: string; + /** Decoding mode. 0: decode by PTS; 1: decode at maximum speed. */ + mode?: number; +} + +export interface SystemInfo { + /** Client platform. */ + platform: 'ios' | 'android' | 'windows' | 'mac' | 'devtools'; + /** Device pixel ratio. */ + pixelRatio: number; +} diff --git a/pagx/wechat/ts/pagx-check.ts b/pagx/wechat/ts/pagx-check.ts new file mode 100644 index 0000000000..3751a89a4f --- /dev/null +++ b/pagx/wechat/ts/pagx-check.ts @@ -0,0 +1,915 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { getSystemInfo } from './wx-system-info'; +declare const wx: any; + +/** + * PAGX render-risk evaluator for mini-program (WeChat) target. + * + * Risk model: "max-risk-path" scoring (May 2026 calibration, high-end device as baseline). + * + * Five independent failure paths: + * A. "BgBlur × uncacheable below" bg_count × (inner + blur + grad/10) + * B. "Path geometry overload" path_data_bytes (MB) + * C. "Big canvas × element density" (pix_M/100) × (imgPat + layer/30 + grad/20) + * D. "BgBlur count" bg_count + * E. "Layer XML count" raw count in source XML + * + * Rendering recommendation (decided by runtime platform): + * - Android: score >= 65 renders smoothly, otherwise it may stutter + * - Other platforms (iOS, etc.): score >= 75 renders smoothly, otherwise it may stutter + * + * Thresholds are adjusted dynamically by device performance level (benchmarkLevel): + * - High-end devices: use the default thresholds (calibration baseline) + * - Mid-range devices: tighten the thresholds + * - Low-end devices: tighten the thresholds further with stricter limits + */ + +// ============================================================================ +// Types +// ============================================================================ + +type DeviceTier = 'high' | 'mid' | 'low' | 'unknown'; +type Platform = 'ios' | 'android' | 'other'; + +/** Full result returned by checkPagx */ +export interface PagxCheckResult { + /** Score (0-100), higher means smoother; Android >= 65 renders normally, other platforms >= 75 render normally */ + score: number; + /** Device performance level (raw value returned by the WeChat API, -1 means unknown) */ + benchmarkLevel: number; + /** Device tier */ + deviceTier: DeviceTier; + /** Platform */ + platform: Platform; +} + +// ============================================================================ +// Device Info Cache +// ============================================================================ + +interface DeviceInfoCache { + tier: DeviceTier; + benchmarkLevel: number; + platform: Platform; +} + +let cachedDeviceInfo: DeviceInfoCache | null = null; + +/** + * Determine the device tier from benchmarkLevel and platform. + * + * Android platform: + * - High tier: >=30 (Xiaomi 15, OPPO Find X8) + * - Mid tier: 23-29 (HONOR 200, REDMI K40) + * - Low tier: <=22 (HONOR Play 20, VIVO Y52s) + * + * iOS platform: + * - High tier: >=36 (iPhone 15/16/17) + * - Mid tier: 30-35 (iPhone 11/12) + * - Low tier: <=29 (iPhone 7/8/X) + */ +function getDeviceTier(benchmarkLevel: number, platform: string): DeviceTier { + // benchmarkLevel = -1 means performance is unknown + if (benchmarkLevel < 0) return 'unknown'; + + if (platform === 'ios') { + if (benchmarkLevel >= 36) return 'high'; + if (benchmarkLevel >= 30) return 'mid'; + return 'low'; + } else { + // Android or other platforms + if (benchmarkLevel >= 30) return 'high'; + if (benchmarkLevel >= 23) return 'mid'; + return 'low'; + } +} + +/** + * Fetch device info asynchronously (calls wx.getDeviceBenchmarkInfo). + */ +async function fetchDeviceInfoAsync(): Promise { + // Return directly if already cached + if (cachedDeviceInfo !== null) { + return cachedDeviceInfo; + } + + try { + const systemInfo = await getSystemInfo(); + const rawPlatform = systemInfo.platform || 'android'; + const platform: Platform = rawPlatform === 'ios' ? 'ios' : rawPlatform === 'android' ? 'android' : 'other'; + const benchmarkLevel = systemInfo.benchmarkLevel; + const tier = getDeviceTier(benchmarkLevel, rawPlatform); + cachedDeviceInfo = { tier, benchmarkLevel, platform }; + } catch { + // Fetch failed, fall back to a conservative unknown + cachedDeviceInfo = { tier: 'unknown', benchmarkLevel: -1, platform: 'other' }; + } + + return cachedDeviceInfo; +} + +// ============================================================================ +// Risk Path Configuration +// ============================================================================ + +/** + * Threshold multipliers for the different device tiers. + * - High-end: baseline (1.0x) + * - Mid-range: tightened to 0.77x + * - Low-end: tightened to 0.54x + * - Unknown: conservative, uses 0.62x + * + * Note: the original calibration data was based on mid-range devices; it now uses + * high-end devices as the baseline. + * Original multipliers: high=1.3, mid=1.0, low=0.7, unknown=0.8 + * After conversion: high=1.0, mid=0.77, low=0.54, unknown=0.62 + */ +const TIER_MULTIPLIERS: Record = { + high: 1.0, + mid: 0.77, + low: 0.54, + unknown: 0.62, +}; + +/** + * Platform multipliers: iOS mini-program rendering performs worse than Android, so it needs stricter thresholds. + * + * Root-cause analysis: + * - The WebGL implementation of the iOS mini-program WebView differs from Android + * - iOS handles complex layer compositing less efficiently + * - iOS mini-programs manage memory more strictly and trigger GC more easily + * + * Multiplier notes (applied to the thresholds; a smaller multiplier lowers the threshold and makes scoring stricter): + * - iOS: 0.6 (threshold lowered to 60%, stricter) + * - Android: 1.0 (baseline) + * - Other: 0.8 (conservative) + */ +const PLATFORM_MULTIPLIERS: Record = { + ios: 0.6, + android: 1.0, + other: 0.8, +}; + +interface RiskPathConfig { + yellow: number; + red: number; +} + +const BASE_RISK_PATHS: Record = { + A_bg_x_uncacheable: { yellow: 2000, red: 4000 }, + B_path_data_MB: { yellow: 3, red: 25 }, + C_canvas_x_density: { yellow: 1500, red: 5000 }, + bg_blur_count: { yellow: 200, red: 400 }, + layer_xml: { yellow: 15000, red: 30000 }, +}; + +const LOCAL_RISK_PATHS = { + bgBlurAreaRadius: { yellow: 25, red: 120 }, + imageLoadMP: { yellow: 80, red: 220 }, + imageRuntimeRisk: { yellow: 45, red: 95 }, +}; +const NEUTRAL_SDK_BG_UNCACHEABLE_RISK = BASE_RISK_PATHS.A_bg_x_uncacheable; +const BARELY_USABLE_SCORE = 50; +const NEUTRAL_SDK_GREEN_SCORE = 75; +const SMALL_AREA_BLUR_RISK = 25; +const LARGE_DOWNSCALE_RATIO = 16; + +interface LocalRiskRaw { + bgBlurAreaRadius: number; + imageDecodeMP: number; + imageDownscaleMP: number; + sdkBgUncacheableRisk: number; + docMP: number; + layerCount: number; + textCount: number; + innerShadowCount: number; +} + +interface ImageRiskState { + uniqueOriginalPixels: Map; + downscaleOriginalPixels: Map; + patternIndex: number; +} + +interface LayerScanState { + maxShapeArea: number; + bgBlurRadii: number[]; +} + +/** + * Adjust the thresholds by device tier and platform. + * Final multiplier = device-tier multiplier × platform multiplier + */ +function getAdjustedRiskPaths(tier: DeviceTier, platform: Platform): Record { + const tierMultiplier = TIER_MULTIPLIERS[tier]; + const platformMultiplier = PLATFORM_MULTIPLIERS[platform]; + const finalMultiplier = tierMultiplier * platformMultiplier; + + const adjusted: Record = {}; + + for (const [key, cfg] of Object.entries(BASE_RISK_PATHS)) { + adjusted[key] = { + yellow: cfg.yellow * finalMultiplier, + red: cfg.red * finalMultiplier, + }; + } + + return adjusted; +} + +// ============================================================================ +// XML Parser (lightweight) +// ============================================================================ + +interface XmlNode { + tag: string; + attribs: Record; + children: XmlNode[]; +} + +const ENTITY_MAP: Record = { + amp: '&', + lt: '<', + gt: '>', + quot: '"', + apos: "'", +}; + +function decodeEntities(value: string): string { + if (value.indexOf('&') === -1) return value; + return value.replace(/&(#x[0-9a-fA-F]+|#[0-9]+|[a-zA-Z]+);/g, (raw, body: string) => { + if (body.charAt(0) === '#') { + const codePoint = + body.charAt(1) === 'x' ? parseInt(body.substring(2), 16) : parseInt(body.substring(1), 10); + if (!Number.isFinite(codePoint) || codePoint < 0) return raw; + try { + return String.fromCodePoint(codePoint); + } catch { + return raw; + } + } + const replacement = ENTITY_MAP[body]; + return replacement !== undefined ? replacement : raw; + }); +} + +// Skips constructs whose payload may contain unbalanced '>' (e.g. DOCTYPE internal subsets). +// Returns the index right after the closing '>'. +function skipBracketedBlock(xmlString: string, start: number): number { + const len = xmlString.length; + let depth = 0; + for (let j = start; j < len; j++) { + const ch = xmlString[j]; + if (ch === '[') { + depth++; + } else if (ch === ']') { + if (depth > 0) depth--; + } else if (ch === '>' && depth === 0) { + return j + 1; + } + } + return len; +} + +function parseXml(xmlString: string): XmlNode | null { + const stack: XmlNode[] = []; + let root: XmlNode | null = null; + let i = 0; + const len = xmlString.length; + + if (xmlString.startsWith(''); + if (endDecl !== -1) i = endDecl + 2; + } + + while (i < len) { + while (i < len && /\s/.test(xmlString[i])) i++; + if (i >= len) break; + + if (xmlString[i] === '<') { + if (xmlString[i + 1] === '/') { + const endTag = xmlString.indexOf('>', i); + if (endTag === -1) break; + stack.pop(); + i = endTag + 1; + } else if (xmlString[i + 1] === '!') { + if (xmlString.substring(i, i + 4) === '', i); + i = endComment === -1 ? len : endComment + 3; + } else if (xmlString.substring(i, i + 9) === '', i); + i = endCdata === -1 ? len : endCdata + 3; + } else { + // Handles , and other declarations whose internal + // subsets may contain '>' inside '[...]'. + i = skipBracketedBlock(xmlString, i + 2); + } + } else if (xmlString[i + 1] === '?') { + const endPI = xmlString.indexOf('?>', i); + i = endPI === -1 ? len : endPI + 2; + } else { + const tagEnd = xmlString.indexOf('>', i); + if (tagEnd === -1) break; + + const tagContent = xmlString.substring(i + 1, tagEnd); + const selfClosing = tagContent.endsWith('/'); + const cleanContent = selfClosing + ? tagContent.slice(0, -1).trim() + : tagContent.trim(); + + const spaceIdx = cleanContent.search(/\s/); + const tagName = + spaceIdx === -1 ? cleanContent : cleanContent.substring(0, spaceIdx); + const attribStr = spaceIdx === -1 ? '' : cleanContent.substring(spaceIdx); + + const attribs: Record = {}; + // Accepts both double-quoted and single-quoted attribute values, and decodes + // standard XML entities (& < > " ' plus numeric refs). + const attrRegex = /([\w:-]+)\s*=\s*("([^"]*)"|'([^']*)')/g; + let match; + while ((match = attrRegex.exec(attribStr)) !== null) { + const rawValue = match[3] !== undefined ? match[3] : match[4]; + attribs[match[1]] = decodeEntities(rawValue); + } + + const node: XmlNode = { tag: tagName, attribs, children: [] }; + + if (stack.length > 0) { + stack[stack.length - 1].children.push(node); + } else { + root = node; + } + + if (!selfClosing) stack.push(node); + i = tagEnd + 1; + } + } else { + const nextTag = xmlString.indexOf('<', i); + i = nextTag === -1 ? len : nextTag; + } + } + + return root; +} + +function collectNodes(node: XmlNode, results: XmlNode[]): void { + results.push(node); + for (const child of node.children) { + collectNodes(child, results); + } +} + +function iterNodes(node: XmlNode): XmlNode[] { + const results: XmlNode[] = []; + collectNodes(node, results); + return results; +} + +function findAllByTag(node: XmlNode, tagName: string): XmlNode[] { + return iterNodes(node).filter((n) => n.tag === tagName); +} + +// ============================================================================ +// Composition Expansion (for accurate risk calculation) +// ============================================================================ + +/** + * Build the Composition lookup table. + * Extracts all Composition definitions from the Resources node. + */ +function buildCompositionLookup(root: XmlNode): Map { + const lookup = new Map(); + + // Find the Resources node + const resources = root.children.find((c) => c.tag === 'Resources'); + if (!resources) return lookup; + + // Collect all Composition definitions + for (const comp of findAllByTag(resources, 'Composition')) { + const id = comp.attribs.id; + if (id) { + lookup.set(id, comp); + } + } + + return lookup; +} + +/** + * Count the tags inside a single Composition (cached, guards against circular references). + * Returns the tag counts inside the Composition definition itself (without expanding nested references). + */ +function getCompositionTagCounts( + compId: string, + compLookup: Map, + cache: Map>, +): Map { + // Return directly if already cached + if (cache.has(compId)) { + return cache.get(compId)!; + } + + const comp = compLookup.get(compId); + if (!comp) { + const empty = new Map(); + cache.set(compId, empty); + return empty; + } + + // Guard against circular references: set an empty Map first + const counts = new Map(); + cache.set(compId, counts); + + // Recursively count the tags inside the Composition (expands nested Composition references) + countTagsInNodeInternal(comp, counts, compLookup, cache); + + return counts; +} + +/** + * Recursively count the tags inside a node (internal helper). + * Expands Composition references when encountered. + */ +function countTagsInNodeInternal( + node: XmlNode, + counts: Map, + compLookup: Map, + cache: Map>, +): void { + // Count the current node + counts.set(node.tag, (counts.get(node.tag) || 0) + 1); + + // If it is a Layer that references a Composition, fetch and accumulate the Composition tag counts + if (node.tag === 'Layer') { + const compRef = node.attribs.composition; + if (compRef && compRef.startsWith('@')) { + const refId = compRef.slice(1); + const refCounts = getCompositionTagCounts(refId, compLookup, cache); + // Merge the referenced Composition tag counts (accumulate into the current counts) + for (const [tag, count] of refCounts) { + counts.set(tag, (counts.get(tag) || 0) + count); + } + } + } + + // Recurse into child nodes + for (const child of node.children) { + countTagsInNodeInternal(child, counts, compLookup, cache); + } +} + +/** + * Count the tags across the whole document (expanding Composition references). + * Counts only the body part (excluding Resources), but expands Composition references. + */ +function countTagsWithExpansion(root: XmlNode): Map { + const compLookup = buildCompositionLookup(root); + const cache = new Map>(); + const totalCounts = new Map(); + + // Traverse only the body nodes (excluding Resources) + for (const child of root.children) { + if (child.tag === 'Resources') continue; + countTagsInNodeInternal(child, totalCounts, compLookup, cache); + } + + return totalCounts; +} + +/** + * Count the raw tags in the XML (without expanding Composition). + * Used by the layer_xml risk path, kept consistent with the Python version. + */ +function countTagsRaw(root: XmlNode): Map { + const counts = new Map(); + for (const node of iterNodes(root)) { + counts.set(node.tag, (counts.get(node.tag) || 0) + 1); + } + return counts; +} + +/** + * Compute the total Path data byte count after expansion. + */ +function countPathDataWithExpansion(root: XmlNode): number { + const compLookup = buildCompositionLookup(root); + const pathDataCache = new Map(); + + // Compute the Path data byte count for a single Composition (cached) + function getCompPathData(compId: string): number { + if (pathDataCache.has(compId)) { + return pathDataCache.get(compId)!; + } + + const comp = compLookup.get(compId); + if (!comp) { + pathDataCache.set(compId, 0); + return 0; + } + + // Guard against circular references: set 0 first + pathDataCache.set(compId, 0); + const bytes = countPathDataInNode(comp); + pathDataCache.set(compId, bytes); + return bytes; + } + + // Recursively compute the Path data of a node + function countPathDataInNode(node: XmlNode): number { + let total = 0; + + // If it is a Path, accumulate the data length + if (node.tag === 'Path') { + const d = node.attribs.data || ''; + if (d) total += d.length; + } + + // If it is a Layer that references a Composition + if (node.tag === 'Layer') { + const compRef = node.attribs.composition; + if (compRef && compRef.startsWith('@')) { + total += getCompPathData(compRef.slice(1)); + } + } + + // Recurse into child nodes + for (const child of node.children) { + total += countPathDataInNode(child); + } + + return total; + } + + // Count only the body part + let totalBytes = 0; + for (const child of root.children) { + if (child.tag === 'Resources') continue; + totalBytes += countPathDataInNode(child); + } + + return totalBytes; +} + +// ============================================================================ +// Core Functions +// ============================================================================ + +function normalize(value: number, yellow: number, red: number): number { + if (value <= 0) return 100.0; + if (value <= yellow) return 100.0 - (value / yellow) * 19.0; + if (value <= red) return 81.0 - ((value - yellow) / (red - yellow)) * 62.0; + return (19.0 * red) / value; +} + +function readNumericAttr(attribs: Record, name: string): number { + const value = Number(attribs[name]); + return Number.isFinite(value) ? value : 0; +} + +function readShapeArea(node: XmlNode): number { + const size = node.attribs.size; + if (!size) return 0; + const [widthText, heightText] = size.split(','); + const width = Number(widthText); + const height = Number(heightText); + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return 0; + return width * height; +} + +function currentLayerState(stack: LayerScanState[]): LayerScanState | null { + return stack.length > 0 ? stack[stack.length - 1] : null; +} + +function addImageRisk(node: XmlNode, state: ImageRiskState): void { + const origWidth = readNumericAttr(node.attribs, 'data-orig-image-width'); + const origHeight = readNumericAttr(node.attribs, 'data-orig-image-height'); + const nodeWidth = readNumericAttr(node.attribs, 'data-node-width'); + const nodeHeight = readNumericAttr(node.attribs, 'data-node-height'); + const originalPixels = Math.max(0, origWidth) * Math.max(0, origHeight); + const displayPixels = Math.max(0, nodeWidth) * Math.max(0, nodeHeight); + if (originalPixels <= 0) return; + + const imageKey = node.attribs.image || `__inline_${state.patternIndex}`; + state.patternIndex += 1; + state.uniqueOriginalPixels.set( + imageKey, + Math.max(state.uniqueOriginalPixels.get(imageKey) ?? 0, originalPixels), + ); + + if (displayPixels > 0 && originalPixels / displayPixels >= LARGE_DOWNSCALE_RATIO) { + state.downscaleOriginalPixels.set( + imageKey, + Math.max(state.downscaleOriginalPixels.get(imageKey) ?? 0, originalPixels), + ); + } +} + +function scanLocalRisk(root: XmlNode): LocalRiskRaw { + const docWidth = readNumericAttr(root.attribs, 'width'); + const docHeight = readNumericAttr(root.attribs, 'height'); + const raw: LocalRiskRaw = { + bgBlurAreaRadius: 0, + imageDecodeMP: 0, + imageDownscaleMP: 0, + sdkBgUncacheableRisk: 0, + docMP: Math.max(0, docWidth) * Math.max(0, docHeight) / 1_000_000, + layerCount: 0, + textCount: 0, + innerShadowCount: 0, + }; + const layerStack: LayerScanState[] = []; + const imageState: ImageRiskState = { + uniqueOriginalPixels: new Map(), + downscaleOriginalPixels: new Map(), + patternIndex: 0, + }; + let bgBlurCount = 0; + let innerShadowCount = 0; + let blurFilterCount = 0; + let gradientCount = 0; + + function visit(node: XmlNode): void { + let layerState: LayerScanState | null = null; + if (node.tag === 'Layer') { + raw.layerCount += 1; + layerState = { maxShapeArea: 0, bgBlurRadii: [] }; + layerStack.push(layerState); + } + + if (node.tag === 'Text' || node.tag === 'GlyphRun') raw.textCount += 1; + if (node.tag === 'Rectangle' || node.tag === 'Ellipse') { + const currentLayer = currentLayerState(layerStack); + const area = readShapeArea(node); + if (currentLayer && area > currentLayer.maxShapeArea) currentLayer.maxShapeArea = area; + } + if (node.tag === 'BackgroundBlurStyle') { + bgBlurCount += 1; + const currentLayer = currentLayerState(layerStack); + if (currentLayer) { + currentLayer.bgBlurRadii.push( + Math.max(readNumericAttr(node.attribs, 'blurX'), readNumericAttr(node.attribs, 'blurY'), 0), + ); + } + } + if (node.tag === 'InnerShadowStyle') { + innerShadowCount += 1; + raw.innerShadowCount += 1; + } + if (node.tag === 'BlurFilter') blurFilterCount += 1; + if (node.tag === 'LinearGradient' || node.tag === 'RadialGradient' || node.tag === 'SweepGradient') { + gradientCount += 1; + } + if (node.tag === 'ImagePattern') addImageRisk(node, imageState); + + for (const child of node.children) visit(child); + + if (node.tag === 'Layer' && layerState) { + const areaMP = layerState.maxShapeArea / 1_000_000; + for (const radius of layerState.bgBlurRadii) raw.bgBlurAreaRadius += areaMP * radius; + layerStack.pop(); + const parentLayer = currentLayerState(layerStack); + if (parentLayer && layerState.maxShapeArea > parentLayer.maxShapeArea) { + parentLayer.maxShapeArea = layerState.maxShapeArea; + } + } + } + + visit(root); + raw.imageDecodeMP = Array.from(imageState.uniqueOriginalPixels.values()) + .reduce((sum, pixels) => sum + pixels, 0) / 1_000_000; + raw.imageDownscaleMP = Array.from(imageState.downscaleOriginalPixels.values()) + .reduce((sum, pixels) => sum + pixels, 0) / 1_000_000; + raw.sdkBgUncacheableRisk = bgBlurCount * (innerShadowCount + blurFilterCount + gradientCount / 10); + return raw; +} + +function scoreBgBlurAreaRadius(value: number): number { + if (value <= 0) return 100; + return Math.max( + normalize(value, LOCAL_RISK_PATHS.bgBlurAreaRadius.yellow, LOCAL_RISK_PATHS.bgBlurAreaRadius.red), + BARELY_USABLE_SCORE, + ); +} + +// Empirical calibration weights for the image runtime-risk heuristic, mirroring the Python +// reference scorer. Each divisor normalizes a raw count/area into the formula's unit scale. +const RUNTIME_RISK_DOWNSCALE_BASELINE_MP = 10; +const RUNTIME_RISK_LAYER_COMPLEXITY_DIVISOR = 1500; +const RUNTIME_RISK_TEXT_COMPLEXITY_DIVISOR = 400; +const RUNTIME_RISK_INNER_SHADOW_COMPLEXITY_DIVISOR = 10; +const RUNTIME_RISK_BG_BLUR_RADIUS_DIVISOR = 60; +const RUNTIME_RISK_LAYOUT_LAYER_DIVISOR = 6000; +const RUNTIME_RISK_LAYOUT_DOC_MP_DIVISOR = 100; + +function getImageRuntimeRisk(raw: LocalRiskRaw): number { + const effectiveDownscaleMP = Math.max(0, raw.imageDownscaleMP - RUNTIME_RISK_DOWNSCALE_BASELINE_MP); + if (effectiveDownscaleMP <= 0) return 0; + + const contentComplexity = raw.layerCount / RUNTIME_RISK_LAYER_COMPLEXITY_DIVISOR + + raw.textCount / RUNTIME_RISK_TEXT_COMPLEXITY_DIVISOR + + raw.innerShadowCount / RUNTIME_RISK_INNER_SHADOW_COMPLEXITY_DIVISOR; + const bgBlurInteraction = raw.bgBlurAreaRadius > 0 + && raw.bgBlurAreaRadius <= LOCAL_RISK_PATHS.bgBlurAreaRadius.red + ? (raw.bgBlurAreaRadius / RUNTIME_RISK_BG_BLUR_RADIUS_DIVISOR) * contentComplexity + : 0; + const layoutInteraction = raw.imageDownscaleMP >= LOCAL_RISK_PATHS.imageLoadMP.yellow + ? raw.layerCount / RUNTIME_RISK_LAYOUT_LAYER_DIVISOR + raw.docMP / RUNTIME_RISK_LAYOUT_DOC_MP_DIVISOR + : 0; + return effectiveDownscaleMP * (bgBlurInteraction + layoutInteraction); +} + +function scoreImageLoadRisk(raw: LocalRiskRaw): number { + return normalize( + Math.max(raw.imageDecodeMP, raw.imageDownscaleMP), + LOCAL_RISK_PATHS.imageLoadMP.yellow, + LOCAL_RISK_PATHS.imageLoadMP.red, + ); +} + +function scoreImageRuntimeRisk(raw: LocalRiskRaw): number { + return normalize( + getImageRuntimeRisk(raw), + LOCAL_RISK_PATHS.imageRuntimeRisk.yellow, + LOCAL_RISK_PATHS.imageRuntimeRisk.red, + ); +} + +function scoreLocalRisk(raw: LocalRiskRaw): number { + return Math.min( + scoreBgBlurAreaRadius(raw.bgBlurAreaRadius), + scoreImageLoadRisk(raw), + scoreImageRuntimeRisk(raw), + ); +} + +function scoreNeutralSdkRisk(raw: LocalRiskRaw): number { + return normalize( + raw.sdkBgUncacheableRisk, + NEUTRAL_SDK_BG_UNCACHEABLE_RISK.yellow, + NEUTRAL_SDK_BG_UNCACHEABLE_RISK.red, + ); +} + +function protectDeviceSpread(baseScore: number, localScore: number, neutralScore: number, raw: LocalRiskRaw): number { + const isSmallAreaBlur = raw.bgBlurAreaRadius > 0 && raw.bgBlurAreaRadius <= SMALL_AREA_BLUR_RISK; + if (baseScore < BARELY_USABLE_SCORE + && localScore >= BARELY_USABLE_SCORE + && (neutralScore >= NEUTRAL_SDK_GREEN_SCORE || isSmallAreaBlur)) { + return BARELY_USABLE_SCORE; + } + return baseScore; +} + +function uint8ArrayToString(data: Uint8Array): string { + if (typeof TextDecoder !== 'undefined') { + return new TextDecoder('utf-8').decode(data); + } + const chunkSize = 8192; + let result = ''; + for (let i = 0; i < data.length; i += chunkSize) { + const chunk = data.subarray(i, Math.min(i + chunkSize, data.length)); + result += String.fromCharCode.apply(null, Array.from(chunk)); + } + try { + return decodeURIComponent(escape(result)); + } catch { + return result; + } +} + +// ============================================================================ +// Main Export Function +// ============================================================================ + +/** + * Evaluate the render-stutter risk of a PAGX file; a higher score means smoother rendering. + * + * This is an **optional** diagnostic utility — it is NOT required for rendering. The module is + * built as a standalone JS file (`lib/pagx-check.js`) with no dependency on the WASM renderer + * or WebGL context. Hosts that do not need pre-render stutter detection can omit it entirely. + * + * Rendering recommendation (decided by runtime platform): + * - Android: score >= 65 renders normally + * - Other platforms (iOS, etc.): score >= 75 renders normally + * + * It automatically fetches device performance info and adjusts the thresholds by device tier: + * - High-end (benchmarkLevel >=30/36): use the default thresholds (calibration baseline) + * - Mid-range (benchmarkLevel 23-29/30-35): tightened thresholds + * - Low-end (benchmarkLevel <=22/29): thresholds tightened further + * + * @param pagxData - Binary data of the PAGX file (Uint8Array) + * @returns Promise - contains the score, device performance level and device tier + * + * @example + * ```typescript + * import { CheckPagx } from './pagx-check'; + * + * const fs = wx.getFileSystemManager(); + * const data = fs.readFileSync(filePath); + * const result = await CheckPagx(new Uint8Array(data)); + * + * const minScore = result.platform === 'android' ? 65 : 75; + * if (result.score < minScore) { + * wx.showToast({ title: 'This file may cause stutter', icon: 'none' }); + * } + * ``` + */ +export async function CheckPagx(pagxData: Uint8Array): Promise { + // Fetch device info first (device info must be returned even if parsing fails) + const deviceInfo = await fetchDeviceInfoAsync(); + + // Default return value (used when parsing fails) + const defaultResult: PagxCheckResult = { + score: 0, + benchmarkLevel: deviceInfo.benchmarkLevel, + deviceTier: deviceInfo.tier, + platform: deviceInfo.platform, + }; + + // Convert the Uint8Array to a string + let pagxContent: string; + try { + pagxContent = uint8ArrayToString(pagxData); + } catch { + return defaultResult; + } + + // Parse the XML + const root = parseXml(pagxContent); + if (!root) return defaultResult; + + // Adjust the thresholds by device tier and platform + const riskPaths = getAdjustedRiskPaths(deviceInfo.tier, deviceInfo.platform); + + // Read the document dimensions (use readNumericAttr like scanLocalRisk to avoid Number/parseFloat parsing differences) + const docWidth = readNumericAttr(root.attribs, 'width'); + const docHeight = readNumericAttr(root.attribs, 'height'); + + // Count the raw XML tags (not expanded, used by the layer_xml risk path) + const rawTagCounts = countTagsRaw(root); + + // Count the tags after expanding Composition (used for the actual rendering-cost calculation) + const expandedTagCounts = countTagsWithExpansion(root); + + // Key metrics (use the expanded counts to reflect the real rendering cost) + const bgBlurCount = expandedTagCounts.get('BackgroundBlurStyle') || 0; + const imagePattern = expandedTagCounts.get('ImagePattern') || 0; + const innerShadowCount = expandedTagCounts.get('InnerShadowStyle') || 0; + const blurFilterCount = expandedTagCounts.get('BlurFilter') || 0; + const gradientCount = + (expandedTagCounts.get('LinearGradient') || 0) + + (expandedTagCounts.get('RadialGradient') || 0) + + (expandedTagCounts.get('SweepGradient') || 0); + + // Compute the total Path data byte count (expanding Composition references) + const pathDataBytes = countPathDataWithExpansion(root); + + // layer_xml uses the raw XML count (consistent with the Python version) + const layerXml = rawTagCounts.get('Layer') || 0; + + // The expanded Layer count is used for the density calculation of path C + const expandedLayerCount = expandedTagCounts.get('Layer') || 0; + + const docPixels = Math.floor(docWidth * docHeight); + const pixM = docPixels / 1_000_000; + + // Compute the raw values of the five risk paths + const riskRaw: Record = { + // Path A: BgBlur × uncacheable elements (uses the expanded counts) + A_bg_x_uncacheable: + bgBlurCount * (innerShadowCount + blurFilterCount + gradientCount / 10), + // Path B: Path data volume (uses the expanded counts) + B_path_data_MB: pathDataBytes / (1024 * 1024), + // Path C: canvas × element density (uses the expanded Layer count) + C_canvas_x_density: + (pixM / 100) * (imagePattern + expandedLayerCount / 30 + gradientCount / 20), + // Path D: BgBlur count (uses the expanded counts) + bg_blur_count: bgBlurCount, + // Path E: Layer XML count (uses the raw XML count, consistent with the Python version) + layer_xml: layerXml, + }; + + // Compute the contribution score of each path and take the minimum + let minScore = 100; + for (const [key, cfg] of Object.entries(riskPaths)) { + const score = normalize(riskRaw[key], cfg.yellow, cfg.red); + if (score < minScore) minScore = score; + } + + // Supplementary calibration from real-device business samples: + // - Large-area bgBlur bottoms out at 50 (openable but not recommended) + // - Image pressure only counts as a runtime FPS risk when combined with bgBlur / layers / text / canvas complexity + // - Small-area but high-count blur is kept from being wrongly killed by the SDK A path + const localRiskRaw = scanLocalRisk(root); + const localScore = scoreLocalRisk(localRiskRaw); + const neutralScore = scoreNeutralSdkRisk(localRiskRaw); + const protectedBaseScore = protectDeviceSpread(minScore, localScore, neutralScore, localRiskRaw); + + // Return the full result + return { + score: Math.min(protectedBaseScore, localScore), + benchmarkLevel: deviceInfo.benchmarkLevel, + deviceTier: deviceInfo.tier, + platform: deviceInfo.platform, + }; +} diff --git a/pagx/wechat/ts/pagx-view.ts b/pagx/wechat/ts/pagx-view.ts new file mode 100644 index 0000000000..b06380a80a --- /dev/null +++ b/pagx/wechat/ts/pagx-view.ts @@ -0,0 +1,1098 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +import { RenderCanvas, WxCanvas } from './render-canvas'; +import { BackendContext } from './backend-context'; +import { destroyVerify } from './decorators'; +import type { + PAGX, + PAGXViewNative, + ContentTransform, + ImageBoundsEntry, + ImageMetadataEntry, +} from './types'; +import type { wx } from './interfaces'; +import { getSystemInfo } from './wx-system-info'; + +declare const wx: wx; + +// Maximum consecutive draw() failures before the render loop is stopped to avoid infinite +// error-logging when the GL context is lost or permanently broken. +const MAX_CONSECUTIVE_DRAW_FAILURES = 5; + +/** + * Quality tier for an image attached via View.attachNativeImage(). The two tiers map to + * distinct slots on each Image node and obey different lifecycle rules: + * + * - Thumbnail: low-resolution preview (typically <= 256x256). Acts as a fallback when the + * full-resolution texture is missing or has been evicted, so the affected fill area never + * goes blank during progressive loading or memory pressure. Thumbnails are not subject to + * per-frame LRU eviction; they are dropped only when the thumbnail bucket exceeds its + * budget, in which case the oldest entries are silently evicted. + * + * - Full: full-resolution texture. Subject to per-frame LRU eviction when the full bucket + * exceeds its budget; evicted paths fire the onTextureEvict callback so the host can drop + * its own cached copy. Layers whose full texture has been evicted automatically fall back + * to the thumbnail on the next draw. + * + * Numeric values must match pagx::ImageQuality on the C++ side (Thumbnail = 0, Full = 1). + */ +export enum ImageQuality { + Thumbnail = 0, + Full = 1, +} + +/** + * JavaScript handler shape registered via View.setTextureEventHandler(). Every method is + * optional; missing methods are silently skipped on the C++ side. + */ +export interface TextureEventHandler { + /** + * Called by the renderer once per draw whenever an Image node references a filePath whose + * full-quality texture is not currently attached and no in-flight attachNativeImage(Full) + * request has already been queued for the same path. The host is expected to fetch and + * decode the asset asynchronously and resolve via attachNativeImage(filePath, source, Full); + * doing so atomically clears the in-flight marker so the same path is not re-requested + * unless it is evicted again later. + */ + onTextureRequest?: (filePath: string) => void; + + /** + * Called once per draw with every full-quality path that was just dropped by the LRU sweep. + * The host can release the corresponding bytes from its own cache. Thumbnail evictions are + * silent and never fire this callback. + */ + onTextureEvict?: (filePaths: string[]) => void; +} + +interface PanBounds { + minX: number; + maxX: number; + minY: number; + maxY: number; +} + +export interface PAGXViewOptions { + /** + * Auto start rendering loop. default false. + * When true, the view will automatically start rendering after initialization. + */ + autoRender?: boolean; + /** + * Custom render callback. Called before each frame is drawn. + * Can be used for performance monitoring or custom rendering logic. + */ + onBeforeRender?: () => void; + /** + * Custom render callback. Called after each frame is drawn. + * Can be used for performance monitoring or custom rendering logic. + */ + onAfterRender?: () => void; + /** + * Called once when the first frame is rendered successfully. + * Useful for hiding loading indicators after content becomes visible. + */ + onFirstFrame?: () => void; +} + +/** + * PAGXView for WeChat MiniProgram. + * Manages canvas, WebGL context, and C++ PAGXViewWechat instance. + * + * @example Progressive image loading (async decode via host, bypasses wasm libwebp): + * ```ts + * const view = await View.init(module, canvas, { autoRender: true }); + * view.parsePAGX(pagxBytes); + * view.buildLayers(); // Layout settles immediately using orig-image-* + * // recorded in the PAGX file. Shapes whose fill is an + * // ImagePattern stay transparent until their image + * // arrives, everything else paints right away. + * + * for (const path of view.getExternalFilePaths()) { + * (async () => { + * const bytes = await myDownloader(path); // Custom fetch / caching. + * const canvas = await View.decodeImageFromBytes(bytes); // Host-native decode, runs + * // concurrently for each path. + * view.attachNativeImage(path, canvas, ImageQuality.Full); // Swap in on the next frame. + * })(); + * } + * ``` + */ +@destroyVerify +export class View { + /** + * Create PAGXView. + * @param module PAGX module instance + * @param canvas WeChat canvas object + * @param options PAGXView options + */ + public static async init( + module: PAGX, + canvas: WxCanvas, + options: PAGXViewOptions = {} + ): Promise { + const view = new View(module, canvas); + view.pagViewOptions = { ...view.pagViewOptions, ...options }; + + // Fetch device pixel ratio once via the version-adapted API. + try { + const sysInfo = await getSystemInfo(); + view.dpr = sysInfo.pixelRatio || 1; + } catch (_) { + view.dpr = 1; + } + + // Create RenderCanvas + view.renderCanvas = RenderCanvas.from(module, canvas); + view.renderCanvas.retain(); + try { + view.backendContext = view.renderCanvas.backendContext; + + if (!view.backendContext) { + throw new Error('Failed to create backend context'); + } + + // Make context current + if (!view.backendContext.makeCurrent(module)) { + throw new Error('Failed to make context current'); + } + + // Create C++ PAGXViewWechat instance + view.nativeView = module.PAGXView.MakeFrom(canvas.width, canvas.height); + + view.backendContext.clearCurrent(module); + + if (!view.nativeView) { + throw new Error('Failed to create PAGXViewWechat'); + } + + if (view.pagViewOptions.autoRender) { + view.startRendering(); + } + } catch (e) { + // Release any partially-initialized resources so a failure does not leak the GL context + // or the native view, and does not leave a stale retainCount that would block a later + // destroy() (RenderCanvas.from reuses by canvas identity, so the leaked instance would + // otherwise be picked up and over-retained on a retry with the same canvas). + if (view.nativeView) { + view.nativeView.delete(); + view.nativeView = null; + } + view.renderCanvas.release(); + view.renderCanvas = null; + view.backendContext = null; + throw e; + } + + return view; + } + + private module: PAGX; + private nativeView: PAGXViewNative | null = null; + private renderCanvas: RenderCanvas | null = null; + private backendContext: BackendContext | null = null; + private canvas: WxCanvas | null = null; + private pagViewOptions: PAGXViewOptions = { + autoRender: false, + }; + private isDestroyed = false; + private isRendering = false; + private firstFrameCallbackFired = false; + private animationFrameId: number = 0; + private consecutiveDrawFailures = 0; + + // Cached viewport state. Mutated by panBy/pinchBy and by direct + // updateZoomScaleAndOffset() callers; read by getViewportInfo() and the + // pan/zoom clamp math. Kept in canvas-physical-pixel space (offset) and + // unitless (zoom). + private currentZoom = 1; + private currentOffsetX = 0; + private currentOffsetY = 0; + // Single-side padding in logical pixels (default 0 keeps the legacy "fit to + // canvas" behavior for callers that haven't opted into single-frame preview). + // The zoom lower-bound and pan limits are computed against + // (effectiveContent + 2 × paddingLogical × dpr). + private paddingLogical = 0; + + // Device pixel ratio, fetched once via getSystemInfo() during init(). + private dpr = 1; + + // Pinch-zoom session snapshot. Set by beginPinchGesture(), cleared by + // endPinchGesture(). When non-null, pinchBy() computes the new zoom/offset + // from this snapshot rather than the live view — so a continuous pinch + // gesture (typically called every animation frame) is immune to floating- + // point accumulation and to the focal-induced drift that arises when each + // frame's calculation depends on the previous frame's output. + private pinchSnapshot: { zoom: number; offsetX: number; offsetY: number } | null = null; + + private constructor(module: PAGX, canvas: WxCanvas) { + this.module = module; + this.canvas = canvas; + } + + /** + * Register fallback fonts for text rendering. + * Must be called before loadPAGX() to ensure fonts are available during layout. + * @param fontData Font file data (e.g. NotoSansSC-Regular.otf) as Uint8Array. + * @param emojiFontData Emoji font file data (e.g. NotoColorEmoji.ttf) as Uint8Array. + */ + public registerFonts(fontData: Uint8Array, emojiFontData: Uint8Array): void { + if (!this.nativeView) { + throw new Error('Native view not initialized'); + } + this.backendContext!.makeCurrent(this.module); + this.nativeView.registerFonts(fontData, emojiFontData); + this.backendContext!.clearCurrent(this.module); + } + + /** + * Load PAGX file data and build the layer tree in one step. Equivalent to calling parsePAGX() + * followed by buildLayers(). For PAGX files with external image resources, use the three-step + * loading flow instead: parsePAGX() -> getExternalFilePaths() + loadFileData() -> buildLayers(). + * @param pagxData PAGX file data as Uint8Array. + */ + public loadPAGX(pagxData: Uint8Array): void { + if (!this.nativeView) { + throw new Error('Native view not initialized'); + } + this.firstFrameCallbackFired = false; + this.nativeView.loadPAGX(pagxData); + } + + /** + * Parse a PAGX file without building the layer tree. After parsing, call getExternalFilePaths() + * to determine which external resources need to be fetched, load them with loadFileData(), then + * call buildLayers() to finalize the rendering content. + * @param pagxData PAGX file data as Uint8Array. + */ + public parsePAGX(pagxData: Uint8Array): void { + if (!this.nativeView) { + throw new Error('Native view not initialized'); + } + this.firstFrameCallbackFired = false; + this.nativeView.parsePAGX(pagxData); + } + + /** + * Returns external file paths referenced by Image nodes that need to be fetched. Call this after + * parsePAGX() to determine which files to download. Data URIs are excluded from the result. + * @returns An array of relative file path strings. + */ + public getExternalFilePaths(): string[] { + if (!this.nativeView) { + throw new Error('Native view not initialized'); + } + const paths = this.nativeView.getExternalFilePaths(); + try { + const result: string[] = []; + const count = paths.size(); + for (let i = 0; i < count; i++) { + result.push(paths.get(i)); + } + return result; + } finally { + paths.delete(); + } + } + + /** + * Load external file data for an Image node matching the given file path. The binary data is + * embedded into the matching node so the renderer can use it directly. Call this after + * parsePAGX() and before buildLayers() for each path returned by getExternalFilePaths(). + * @param filePath The external file path to match against Image nodes. + * @param fileData The file content as Uint8Array. + * @returns True if a matching Image node was found and its data was loaded successfully. + */ + public loadFileData(filePath: string, fileData: Uint8Array): boolean { + if (!this.nativeView) { + throw new Error('Native view not initialized'); + } + return this.nativeView.loadFileData(filePath, fileData); + } + + /** + * Attaches a host-decoded native image (typically an OffscreenCanvas) to Image nodes + * matching the given filePath under a specific quality tier. + * + * The image is queued for GPU upload; actual texImage2D happens during the next draw() call + * inside flushPendingUploads(). The OffscreenCanvas can be released as soon as this method + * returns. Affected layers are rebuilt in place so the new asset shows up on the next frame. + * + * Two quality tiers may be attached for the same filePath at different times. Typical + * progressive flow: + * 1. Initial: download a small thumbnail variant (e.g. via the imageMogr2 thumbnail + * transform), decode to OffscreenCanvas, attachNativeImage(path, canvas, Thumbnail). + * 2. Later: download the full asset, decode, attachNativeImage(path, canvas, Full). + * The thumbnail keeps the fill non-blank if the full texture is later evicted under memory + * pressure (the renderer transparently falls back to the thumbnail until a new full + * attachment arrives, which the host typically triggers from onTextureRequest). + * + * @param filePath The external file path to match against Image nodes. + * @param nativeImage Host-decoded image source. Standard web TexImageSource or WeChat + * OffscreenCanvas; the latter is detected by the `isOffscreenCanvas` + * discriminator. + * @param quality Quality tier; selects the Image-node slot the upload writes to. + * @returns True when the upload request was queued successfully. False typically means the + * budget cannot fit or the document is not ready. + */ + public attachNativeImage( + filePath: string, + nativeImage: unknown, + quality: ImageQuality + ): boolean { + if (!this.nativeView) { + throw new Error('Native view not initialized'); + } + return this.nativeView.attachNativeImage(filePath, nativeImage, quality); + } + + /** + * Provides the original (full-resolution) pixel dimensions of an external image to the SDK + * so that the new-format PAGX ImagePattern matrix can be corrected when the host attaches a + * downscaled variant. + * + * Background: New-format PAGX bakes the ImagePattern matrix in the original image's pixel + * coordinate system. When the host attaches a scaled-down variant via attachNativeImage(), + * the SDK must post-multiply the authored matrix by diag(origW/actualW, origH/actualH) to + * keep the fill aligned. Without this hint the SDK falls back to using the attached image's + * own dimensions, which produces a misaligned fill. + * + * Call this right after parsePAGX() and before buildLayers() / attachNativeImage(). The + * filePath must match the value returned by getImageMetadata() (i.e. the Image node's + * source path). Calling parsePAGX() again clears the entire table. + * + * @param filePath The external file path identifying the image (matches Image.filePath). + * @param width Original image pixel width; must be > 0. + * @param height Original image pixel height; must be > 0. + */ + public setImageOriginalSize(filePath: string, width: number, height: number): void { + if (!this.nativeView) { + throw new Error('Native view not initialized'); + } + this.nativeView.setImageOriginalSize(filePath, width, height); + } + + /** + * Registers a JavaScript handler that receives backend-texture lifecycle events from this + * view. See TextureEventHandler for the expected method shape; both onTextureRequest and + * onTextureEvict are optional and missing methods are silently skipped. + * + * Pass null/undefined to clear a previously registered handler. The handler survives + * parsePAGX(), so callers typically register it once after init(). + */ + public setTextureEventHandler(handler: TextureEventHandler | null): void { + if (!this.nativeView) { + throw new Error('Native view not initialized'); + } + this.nativeView.setTextureEventHandler(handler); + } + + /** + * Returns true when the SDK's full-quality cache has crossed its hard cap. Progressive + * upgrade loops in the host should call this at the top of every upgrade pass and skip + * new uploads while it returns true. Reactive responses to onTextureRequest remain safe + * (1:1 replacement of just-evicted entries leaves total bytes net-flat). The flag clears + * automatically once a viewport change lets the LRU drain enough off-viewport entries. + * + * Returns false when the native view is not initialized; callers in that state have no + * cache to worry about anyway. + */ + public isFullBudgetSaturated(): boolean { + if (!this.nativeView) { + return false; + } + return this.nativeView.isFullBudgetSaturated(); + } + + /** + * Fetch root-space bounds for the given filePaths. Each entry describes the union of every + * layer that renders the image (for viewport intersection) and the single layer with the + * biggest display area (for focus-distance scoring used by progressive upgrade). Unknown + * filePaths map to null. Must be called after buildLayers(); the first call triggers lazy + * bounds evaluation inside tgfx and is noticeably heavier than later calls, so JS callers + * typically defer it to the first idle window after the initial frame renders. + * + * @param filePaths File paths to look up. Order is preserved in the returned map keys. + */ + public getImageBounds(filePaths: string[]): Record { + if (!this.nativeView) { + throw new Error('Native view not initialized'); + } + return this.nativeView.getImageBounds(filePaths); + } + + /** + * Return ImagePattern usage metadata for every externally referenced image in the currently + * parsed document. Must be called after parsePAGX(). The JS-facing progressive loader uses + * this to choose thumbnail sizes and compute display scale without re-parsing the PAGX XML. + */ + public getImageMetadata(): ImageMetadataEntry[] { + if (!this.nativeView) { + throw new Error('Native view not initialized'); + } + return this.nativeView.getImageMetadata(); + } + + /** + * Build the layer tree from the previously parsed document. Call this after parsePAGX() and any + * loadFileData() calls to finalize the rendering content. + */ + public buildLayers(): void { + if (!this.nativeView) { + throw new Error('Native view not initialized'); + } + this.nativeView.buildLayers(); + } + + /** + * Update canvas size. + * @param width New width + * @param height New height + */ + public updateSize(width?: number, height?: number): void { + if (!this.canvas) { + throw new Error('Canvas element is not found!'); + } + + if (width !== undefined && height !== undefined) { + this.canvas.width = width; + this.canvas.height = height; + } + + if (!this.backendContext) { + return; + } + + this.backendContext.makeCurrent(this.module); + this.nativeView!.updateSize(this.canvas.width, this.canvas.height); + this.backendContext.clearCurrent(this.module); + } + + /** + * Update zoom scale and content offset for display list. + * + * Prefer the higher-level panBy / pinchBy helpers when implementing + * gesture-driven viewport changes — they apply the SDK's clamp policy (zoom + * lower bound, pan limits, padding) automatically and report overscroll. + * This raw setter remains available for callers that already maintain their + * own clamp logic or need an absolute jump. + * @param zoom Zoom scale + * @param offsetX X offset + * @param offsetY Y offset + */ + public updateZoomScaleAndOffset(zoom: number, offsetX: number, offsetY: number): void { + this.currentZoom = zoom; + this.currentOffsetX = offsetX; + this.currentOffsetY = offsetY; + this.nativeView!.updateZoomScaleAndOffset(zoom, offsetX, offsetY); + } + + /** + * Notify that zoom gesture has ended (for adaptive tile refinement). + * Note: This is now handled automatically by PAGXView internal detection. + * No need to call manually unless you want to force trigger. + * @deprecated Automatic detection is enabled, manual call is optional. + */ + public onZoomEnd(): void { + this.nativeView!.onZoomEnd(); + } + + /** + * Sets the bounds origin of the PAGX content relative to the cocraft canvas origin. This + * overrides any values read from the PAGX file's backGroundColor customData. The values can be + * negative. + * @param x The x coordinate of the content bounds origin in cocraft canvas coordinates. + * @param y The y coordinate of the content bounds origin in cocraft canvas coordinates. + */ + public setBoundsOrigin(x: number, y: number): void { + this.nativeView!.setBoundsOrigin(x, y); + } + + /** + * Toggles the gesture-freeze fast path. Call with true on pan/zoom start and false on end. + * On active=true the native side captures the current surface as cachedSnapshot, so we + * wrap with makeCurrent to ensure the GL context is active for the readback. + */ + public setGestureActive(active: boolean): void { + if (active && this.backendContext) { + this.backendContext.makeCurrent(this.module); + this.nativeView!.setGestureActive(active); + this.backendContext.clearCurrent(this.module); + } else { + this.nativeView!.setGestureActive(active); + } + } + + /** + * Toggles the fitSnapshot fast path. When enabled (default), the renderer caches a + * fit-to-canvas snapshot and blits it during gestures and at zoom <= 1.02 to skip the + * full displayList.render() cost. Disable to force a full render every frame, trading + * per-frame rendering cost for first-frame clarity and freshness under progressive + * image loading. The setting persists across loadPAGX/buildLayers. + */ + public setSnapshotEnabled(enabled: boolean): void { + this.nativeView!.setSnapshotEnabled(enabled); + } + + /** + * Returns the content transform parameters for mapping cocraft canvas coordinates to canvas + * pixel positions. Call this once after loading a PAGX file to get the static transform needed + * for comment overlay positioning. + * + * @example + * ```ts + * // Once after loadPAGX(): + * const t = view.getContentTransform(); + * const baseX = (cocraftX - t.boundsOriginX) * t.fitScale + t.centerOffsetX; + * const baseY = (cocraftY - t.boundsOriginY) * t.fitScale + t.centerOffsetY; + * + * // On each zoom/pan gesture callback: + * const screenX = (baseX * zoom + panOffsetX) / dpr; + * const screenY = (baseY * zoom + panOffsetY) / dpr; + * ``` + */ + public getContentTransform(): ContentTransform { + return this.nativeView!.getContentTransform(); + } + + /** + * Configure the single-side reserved padding (logical pixels) used by the + * single-frame preview clamp policy. The zoom lower bound is chosen so that + * the document plus 2× padding cannot shrink below the canvas, and the pan + * limits keep the document edge from leaving the canvas viewport. + * + * Default is 0, which preserves the legacy "fit to canvas" behavior for + * callers that have not opted into single-frame preview. + * + * @param logicalPx Single-side reserved padding in logical pixels (≥ 0). + */ + public setPadding(logicalPx: number): void { + this.paddingLogical = Math.max(0, logicalPx); + } + + /** + * Pan the viewport by an incremental delta (canvas physical pixels). The + * SDK applies the clamp policy and writes the new offset back to the native + * view in one round-trip. + * + * Returns the unconsumed portion of the requested pan in each axis. A + * non-zero `remainingX/Y` means the gesture pushed the document past its + * pan limit — callers can use this signal to drive overscroll UI such as + * page-flip arrows or rubber-band feedback. The consumed portion is simply + * `deltaX − remainingX` (the SDK does not return it explicitly). + * + * Calling `panBy(0, 0)` is a legitimate way to re-clamp the current offset, + * for example after a caller mutates zoom directly via + * updateZoomScaleAndOffset() and the offset is no longer in range. + * + * @param deltaX Horizontal delta in canvas physical pixels. + * @param deltaY Vertical delta in canvas physical pixels. + */ + public panBy(deltaX: number, deltaY: number): { remainingX: number; remainingY: number } { + if (!this.nativeView) { + return { remainingX: 0, remainingY: 0 }; + } + const bounds = this.computePanBounds(); + const desiredX = this.currentOffsetX + deltaX; + const desiredY = this.currentOffsetY + deltaY; + const clampedX = clamp(desiredX, bounds.minX, bounds.maxX); + const clampedY = clamp(desiredY, bounds.minY, bounds.maxY); + if (clampedX !== this.currentOffsetX || clampedY !== this.currentOffsetY + || deltaX !== 0 || deltaY !== 0) { + this.updateZoomScaleAndOffset(this.currentZoom, clampedX, clampedY); + } + return { + remainingX: desiredX - clampedX, + remainingY: desiredY - clampedY, + }; + } + + /** + * Begin a pinch-zoom gesture session. The SDK snapshots the current zoom and + * offset; subsequent pinchBy() calls are computed from this snapshot rather + * than the live view, so the gesture stays free of accumulated floating-point + * drift even when called every frame for hundreds of frames. + * + * Designed to mirror UIPinchGestureRecognizer's "absolute scale" model: + * `scale` in pinchBy() is the cumulative ratio from the gesture start, not a + * per-frame delta. + * + * Calling beginPinchGesture() when a session is already active overwrites the + * snapshot — useful when the host wants to reset the anchor mid-gesture. + * + * @example + * ```ts + * // Two-finger pinch (continuous): + * onTouchStart: view.beginPinchGesture(); initialDist = ...; focalX = ...; + * onTouchMove: view.pinchBy(currentDist / initialDist, focalX, focalY); + * onTouchEnd: view.endPinchGesture(); + * + * // Single-shot zoom (no begin/end needed): + * view.pinchBy(1.1, mouseX, mouseY); // wheel zoom-in 10% + * view.pinchBy(2.0, tapX, tapY); // double-tap zoom-in + * ``` + */ + public beginPinchGesture(): void { + this.pinchSnapshot = { + zoom: this.currentZoom, + offsetX: this.currentOffsetX, + offsetY: this.currentOffsetY, + }; + } + + /** + * Apply a pinch zoom around a focal point. Behavior depends on whether a + * gesture session is active: + * + * - **Inside a session** (between beginPinchGesture/endPinchGesture): + * `scale` is the cumulative ratio from the session start. The SDK + * recomputes the new zoom and offset from the snapshot every call, so + * continuous gestures (e.g. two-finger pinch) are immune to floating-point + * accumulation and to the focal-induced drift caused by per-frame relative + * updates. + * + * - **Without a session** (no begin/end called): + * `scale` is applied to the live view state, equivalent to a one-shot + * absolute zoom. Suitable for discrete inputs like mouse wheel, double-tap, + * or "+/-" buttons where there is no concept of a "gesture start". + * + * In both modes the SDK clamps the resulting zoom to its lower bound (so the + * document plus padding never shrinks below the canvas) and re-clamps the + * offset to the pan limits at the new zoom level. + * + * @param scale Cumulative zoom ratio. Inside a session: relative to the + * snapshot zoom. Without session: relative to the current zoom. + * Values < 1 zoom out, > 1 zoom in. + * @param focalX Anchor X in canvas physical pixels (the point that should + * stay visually fixed during the zoom). + * @param focalY Anchor Y in canvas physical pixels. + */ + public pinchBy(scale: number, focalX: number, focalY: number): void { + if (!this.nativeView) { + return; + } + // Pick the base state: snapshot if in a session, live view otherwise. + // The single-shot path matches the previous (now-removed) zoomBy(scaleDelta) + // semantics so callers using pinchBy for wheel/tap zoom get the same + // result without any extra setup. + const baseZoom = this.pinchSnapshot ? this.pinchSnapshot.zoom : this.currentZoom; + const baseOffsetX = this.pinchSnapshot ? this.pinchSnapshot.offsetX : this.currentOffsetX; + const baseOffsetY = this.pinchSnapshot ? this.pinchSnapshot.offsetY : this.currentOffsetY; + + const minZoom = this.computeMinZoom(); + let newZoom = baseZoom * scale; + if (newZoom < minZoom) newZoom = minZoom; + + // Standard zoom-around-focal formula: + // newOffset = (baseOffset - focal) × (newZoom / baseZoom) + focal + // Computed from the base state, not the live view, so the focal stays + // visually fixed even across hundreds of frames. + const ratio = newZoom / baseZoom; + let newOffsetX = (baseOffsetX - focalX) * ratio + focalX; + let newOffsetY = (baseOffsetY - focalY) * ratio + focalY; + + // Re-clamp the offset against the pan bounds at the new zoom level. + const boundsAtNewZoom = this.computePanBoundsForZoom(newZoom); + newOffsetX = clamp(newOffsetX, boundsAtNewZoom.minX, boundsAtNewZoom.maxX); + newOffsetY = clamp(newOffsetY, boundsAtNewZoom.minY, boundsAtNewZoom.maxY); + + this.updateZoomScaleAndOffset(newZoom, newOffsetX, newOffsetY); + } + + /** + * End a pinch-zoom gesture session and clear the snapshot. Subsequent + * pinchBy() calls revert to single-shot mode (operating on the live view). + * + * Idempotent: calling endPinchGesture() without an active session is a no-op. + */ + public endPinchGesture(): void { + this.pinchSnapshot = null; + } + + /** + * Snapshot of the current viewport. All fields reflect post-clamp state and + * are safe to consume from any rendering or UI code path. Boundary flags + * indicate the document edge has reached the canvas edge in that direction + * (within a 0.5-pixel tolerance) — callers that drive overscroll UI from + * non-pan sources (keyboard arrows, programmatic snap) can read these + * directly instead of inferring from panBy's remaining values. + * + * - zoom / offsetX / offsetY: current display-list transform + * - canvasWidth / canvasHeight: physical-pixel canvas size + * - contentWidth / contentHeight: PAGX document size in content units + * - fitScale: scale factor applied internally to fit the + * document into the canvas in contain mode + * - atLeft / atRight / atTop / atBottom: edge-reached flags + */ + public getViewportInfo(): { + zoom: number; + offsetX: number; + offsetY: number; + canvasWidth: number; + canvasHeight: number; + contentWidth: number; + contentHeight: number; + fitScale: number; + atLeft: boolean; + atRight: boolean; + atTop: boolean; + atBottom: boolean; + } { + const canvasW = this.canvas ? this.canvas.width : 0; + const canvasH = this.canvas ? this.canvas.height : 0; + const contentW = this.nativeView ? this.nativeView.contentWidth() : 0; + const contentH = this.nativeView ? this.nativeView.contentHeight() : 0; + const fitScale = this.nativeView ? this.getContentTransform().fitScale : 1; + const bounds = this.computePanBounds(); + const tol = 0.5; + return { + zoom: this.currentZoom, + offsetX: this.currentOffsetX, + offsetY: this.currentOffsetY, + canvasWidth: canvasW, + canvasHeight: canvasH, + contentWidth: contentW, + contentHeight: contentH, + fitScale, + atLeft: this.currentOffsetX <= bounds.minX + tol, + atRight: this.currentOffsetX >= bounds.maxX - tol, + atTop: this.currentOffsetY <= bounds.minY + tol, + atBottom: this.currentOffsetY >= bounds.maxY - tol, + }; + } + + /** + * Looks up a node by ID and returns its position relative to the canvas. + * @param nodeId The unique identifier of the node to look up. + * @returns An object with { found: boolean, x: number, y: number }. + * If the node is not found or has no position data, found is false and x/y are 0. + */ + public getNodePosition(nodeId: string): { found: boolean; x: number; y: number } { + if (!this.nativeView) { + return { found: false, x: 0, y: 0 }; + } + return this.nativeView.getNodePosition(nodeId); + } + + /** + * Get content width. + */ + public contentWidth(): number { + return this.nativeView!.contentWidth(); + } + + /** + * Get content height. + */ + public contentHeight(): number { + return this.nativeView!.contentHeight(); + } + + /** + * Returns true if the first frame has been rendered successfully. + */ + public isFirstFrameRendered(): boolean { + return this.nativeView!.firstFrameRendered(); + } + + /** + * Drop any cached/fit snapshots captured between parsePAGX and the gesture-state init, + * and reset the first-frame flag so isFirstFrameRendered() reflects post-init renders. + * Call right after applyGestureState() on a freshly loaded document. + */ + public resetForFreshCapture(): void { + this.nativeView!.resetForFreshCapture(); + } + + /** + * Start the rendering loop. + * This will continuously call draw() using requestAnimationFrame. + */ + public startRendering(): void { + if (this.isRendering) { + return; + } + this.isRendering = true; + this.renderLoop(); + } + + /** + * Stop the rendering loop. + */ + public stopRendering(): void { + if (!this.isRendering) { + return; + } + this.isRendering = false; + if (this.animationFrameId) { + if (this.canvas && this.canvas.cancelAnimationFrame) { + this.canvas.cancelAnimationFrame(this.animationFrameId); + } else { + clearTimeout(this.animationFrameId); + } + this.animationFrameId = 0; + } + } + + /** + * Check if the view is currently rendering. + */ + public isCurrentlyRendering(): boolean { + return this.isRendering; + } + + /** + * Set render callback functions for performance monitoring. + */ + public setRenderCallbacks(onBeforeRender?: () => void, onAfterRender?: () => void): void { + this.pagViewOptions.onBeforeRender = onBeforeRender; + this.pagViewOptions.onAfterRender = onAfterRender; + } + + /** + * Destroy PAGXView and release all resources. + */ + public destroy(): void { + if (this.isDestroyed) { + return; + } + + this.stopRendering(); + + if (this.nativeView) { + if (this.backendContext) { + this.backendContext.makeCurrent(this.module); + } + this.nativeView.delete(); + this.nativeView = null; + if (this.backendContext) { + this.backendContext.clearCurrent(this.module); + } + } + + if (this.renderCanvas) { + this.renderCanvas.release(); + this.renderCanvas = null; + } + + this.backendContext = null; + this.canvas = null; + this.pinchSnapshot = null; + this.isDestroyed = true; + } + + /** + * Pan bounds at the current zoom. The interactive virtual canvas is the viewport plus + * padding on each side. Bounds are asymmetric when zoom < 1 so the shrunken virtual + * canvas stays centered instead of snapping to the top-left origin. + */ + private computePanBounds(): PanBounds { + return this.computePanBoundsForZoom(this.currentZoom); + } + + private computePanBoundsForZoom(zoom: number): PanBounds { + if (!this.canvas) { + return { minX: 0, maxX: 0, minY: 0, maxY: 0 }; + } + const paddingPhysical = this.paddingLogical * this.dpr; + const canvasW = this.canvas.width; + const canvasH = this.canvas.height; + let minX = canvasW - (canvasW + paddingPhysical) * zoom; + let maxX = paddingPhysical * zoom; + let minY = canvasH - (canvasH + paddingPhysical) * zoom; + let maxY = paddingPhysical * zoom; + if (minX > maxX) { + const centerX = (minX + maxX) * 0.5; + minX = centerX; + maxX = centerX; + } + if (minY > maxY) { + const centerY = (minY + maxY) * 0.5; + minY = centerY; + maxY = centerY; + } + return { minX, maxX, minY, maxY }; + } + + /** + * Lower bound for zoom under dynamic pan-bound semantics. At min zoom the virtual canvas + * (viewport + padding on each side) just covers the viewport. + * Returns 0 when fields are not yet initialized — callers will subsequently use 1 as the + * fallback minimum. + */ + private computeMinZoom(): number { + if (!this.nativeView || !this.canvas) { + return 0; + } + const paddingPhysical = this.paddingLogical * this.dpr; + const virtualW = this.canvas.width + 2 * paddingPhysical; + const virtualH = this.canvas.height + 2 * paddingPhysical; + return Math.min( + 1, + this.canvas.width / virtualW, + this.canvas.height / virtualH, + ); + } + + private renderLoop(): void { + if (!this.isRendering || this.isDestroyed) { + return; + } + + if (!this.backendContext) { + return; + } + + if (this.pagViewOptions.onBeforeRender) { + this.pagViewOptions.onBeforeRender(); + } + + this.backendContext.makeCurrent(this.module); + try { + this.nativeView!.draw(); + this.consecutiveDrawFailures = 0; + } catch (e) { + this.consecutiveDrawFailures += 1; + console.error('[PAGXView] draw failed in render loop:', e); + if (this.consecutiveDrawFailures >= MAX_CONSECUTIVE_DRAW_FAILURES) { + console.error('[PAGXView] too many consecutive draw failures, stopping render loop.'); + this.backendContext.clearCurrent(this.module); + this.isRendering = false; + return; + } + } + this.backendContext.clearCurrent(this.module); + + if (!this.firstFrameCallbackFired && this.nativeView!.firstFrameRendered()) { + this.firstFrameCallbackFired = true; + if (this.pagViewOptions.onFirstFrame) { + this.pagViewOptions.onFirstFrame(); + } + } + + if (this.pagViewOptions.onAfterRender) { + this.pagViewOptions.onAfterRender(); + } + + if (this.canvas && this.canvas.requestAnimationFrame) { + this.animationFrameId = this.canvas.requestAnimationFrame(() => { + this.renderLoop(); + }); + } else { + this.animationFrameId = setTimeout(() => { + this.renderLoop(); + }, 16) as unknown as number; + } + } + + /** + * Decode an image from a file path (temp file or URL the mini-program can resolve) into an + * OffscreenCanvas. The decoded canvas is what attachNativeImage() expects. This + * utility is deliberately kept as a primitive -- it does not handle downloads or caching -- so + * callers can plug in their own network/caching logic (which is commonly required for CDN + * signing, retry policy, and so on). + * + * The decode itself runs on the mini-program's native decoder (webp/png/jpeg), which is + * multi-threaded off the JS thread, so concurrent decodeImageFromPath() calls can overlap. + * + * @param filePath The local temp path or remote URL. + * @returns A promise that resolves with an OffscreenCanvas containing the decoded pixels. + * Rejects on decode failure. + */ + public static decodeImageFromPath(filePath: string): Promise { + return new Promise((resolve, reject) => { + const canvas = wx.createOffscreenCanvas({ type: '2d' }) as OffscreenCanvas & { + createImage: () => HTMLImageElement; + }; + const img = canvas.createImage(); + let settled = false; + img.onload = () => { + if (settled) return; + settled = true; + canvas.width = img.width; + canvas.height = img.height; + const ctx = canvas.getContext('2d') as OffscreenCanvasRenderingContext2D | null; + if (!ctx) { + reject(new Error('decodeImageFromPath: 2d context unavailable')); + return; + } + ctx.drawImage(img as any, 0, 0); + // Force the OffscreenCanvas's GPU contents to flush before handing it to the renderer. + // On iOS WeChat the drawImage above is GPU-accelerated and may be deferred until the + // canvas is read; calling getImageData synchronously waits for the pipeline to finish, + // so the subsequent texImage2D upload sees actual pixels instead of an undefined + // intermediate state that manifested as black tiles after first-time zoom-in. + try { + ctx.getImageData(0, 0, 1, 1); + } catch (_) { + // Best-effort flush. A failure here only loses the synchronization guarantee, the + // subsequent upload still attempts to consume the canvas as before. + } + resolve(canvas); + }; + img.onerror = (err: any) => { + if (settled) return; + settled = true; + const msg = (err && (err.errMsg || err.message || err.type)) || 'onerror'; + reject(new Error('decodeImageFromPath: ' + msg)); + }; + img.src = filePath; + }); + } + + /** + * Decode an image from its encoded bytes into an OffscreenCanvas. This writes the bytes to a + * short-lived temp file under wx.env.USER_DATA_PATH, runs decodeImageFromPath() on it, then + * deletes the file. Prefer decodeImageFromPath() directly when the caller already has the + * asset on disk or can pass a URL the mini-program can fetch. + * + * @param bytes The encoded image bytes (webp, png, jpeg, ...). + * @param hint An optional filename hint (with extension) used only to build the temp path; + * does not affect decoding. + * @returns A promise that resolves with an OffscreenCanvas containing the decoded pixels. + */ + public static async decodeImageFromBytes( + bytes: ArrayBuffer | Uint8Array, + hint: string = 'pagx_decode' + ): Promise { + const fs = wx.getFileSystemManager(); + const base = (wx as any).env?.USER_DATA_PATH ?? ''; + const unique = `${hint.replace(/[^a-zA-Z0-9._-]/g, '_')}_${Date.now()}_${Math.random() + .toString(36) + .slice(2, 8)}`; + const tempPath = `${base}/${unique}`; + const buffer = bytes instanceof Uint8Array ? bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) : bytes; + fs.writeFileSync(tempPath, buffer); + try { + return await View.decodeImageFromPath(tempPath); + } finally { + try { + fs.unlinkSync(tempPath); + } catch (_) { + // Best-effort cleanup. Leaving a stray temp file is not fatal; the OS/app cleanup will + // eventually reclaim it. + } + } + } +} + +function clamp(v: number, min: number, max: number): number { + if (v < min) return min; + if (v > max) return max; + return v; +} diff --git a/pagx/wechat/ts/pagx.ts b/pagx/wechat/ts/pagx.ts new file mode 100644 index 0000000000..5f268f3633 --- /dev/null +++ b/pagx/wechat/ts/pagx.ts @@ -0,0 +1,48 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +import './babel'; +import * as types from './types'; +import createPAG from '../wasm/pagx-viewer'; +import { PAGX } from './types'; +import { binding } from './binding'; + +export interface moduleOption { + /** + * Link to wasm file. + */ + locateFile?: (file: string) => string; +} + +/** + * Initialize PAGX webassembly module. + */ +const PAGXInit = (moduleOption: moduleOption = {}): Promise => + createPAG(moduleOption) + .then((module: types.PAGX) => { + binding(module); + return module; + }) + .catch((error: any) => { + console.error(error); + throw new Error('PAGXInit fail! Please check .wasm file path valid.'); + }); + +export { PAGXInit, types }; +export { ImageQuality } from './pagx-view'; +export type { TextureEventHandler } from './pagx-view'; \ No newline at end of file diff --git a/pagx/wechat/ts/render-canvas.ts b/pagx/wechat/ts/render-canvas.ts new file mode 100644 index 0000000000..04aadd9eae --- /dev/null +++ b/pagx/wechat/ts/render-canvas.ts @@ -0,0 +1,115 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +import { BackendContext } from './backend-context'; +import type { PAGX } from './types'; + +export type WxCanvas = (HTMLCanvasElement | OffscreenCanvas) & { + requestAnimationFrame?: (callback: () => void) => number; + cancelAnimationFrame?: (requestID: number) => void; +}; + +const renderCanvasList: RenderCanvas[] = []; + +/** + * RenderCanvas manages Canvas and WebGL context lifecycle. + */ +export class RenderCanvas { + /** + * Get or create RenderCanvas from canvas object. + * Reuses existing RenderCanvas if the same canvas is provided. + */ + public static from(module: PAGX, canvas: WxCanvas): RenderCanvas { + let renderCanvas = renderCanvasList.find((rc) => rc.canvas === canvas); + if (renderCanvas) { + return renderCanvas; + } + renderCanvas = new RenderCanvas(module, canvas); + renderCanvasList.push(renderCanvas); + return renderCanvas; + } + + private _canvas: WxCanvas | null = null; + private _backendContext: BackendContext | null = null; + private retainCount = 0; + private module: PAGX; + + private constructor(module: PAGX, canvas: WxCanvas) { + this.module = module; + this._canvas = canvas; + + const contextAttributes: WebGLContextAttributes = { + alpha: true, + depth: false, + stencil: false, + antialias: true, + powerPreference: 'high-performance', + preserveDrawingBuffer: false, + failIfMajorPerformanceCaveat: false, + }; + + // Try standard WebGL 2 context first + let gl = canvas.getContext('webgl2', contextAttributes) as WebGL2RenderingContext | null; + + if (!gl) { + throw new Error( + 'Failed to get WebGL 2 context from canvas. ' + + 'Please ensure your browser/environment supports WebGL 2.', + ); + } + + // Register context to Emscripten + this._backendContext = BackendContext.from(module, gl); + } + + /** + * Increase retain count. + */ + public retain(): void { + this.retainCount += 1; + } + + /** + * Decrease retain count and release resources when count reaches zero. + */ + public release(): void { + this.retainCount -= 1; + if (this.retainCount <= 0) { + // Remove from global list + const index = renderCanvasList.indexOf(this); + if (index >= 0) { + renderCanvasList.splice(index, 1); + } + + // Clean up + if (this._backendContext) { + this._backendContext.destroy(this.module); + this._backendContext = null; + } + this._canvas = null; + } + } + + public get canvas(): WxCanvas | null { + return this._canvas; + } + + public get backendContext(): BackendContext | null { + return this._backendContext; + } +} diff --git a/pagx/wechat/ts/types.ts b/pagx/wechat/ts/types.ts new file mode 100644 index 0000000000..757bff4ac4 --- /dev/null +++ b/pagx/wechat/ts/types.ts @@ -0,0 +1,227 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +import type {TGFX} from '@tgfx/types'; + +import type {View} from './pagx-view'; + +/** + * Native C++ PAGXView interface exposed via Emscripten bindings. + */ +export interface PAGXViewNative { + /** Returns the canvas width in pixels. */ + width: () => number; + /** Returns the canvas height in pixels. */ + height: () => number; + /** Registers fallback fonts for text rendering. */ + registerFonts: (fontData: Uint8Array, emojiFontData: Uint8Array) => void; + /** Loads a PAGX file and builds the layer tree in one step. */ + loadPAGX: (data: Uint8Array) => boolean; + /** Parses a PAGX file without building layers. Use with getExternalFilePaths(), loadFileData(), and buildLayers(). */ + parsePAGX: (data: Uint8Array) => void; + /** Returns external file paths referenced by Image nodes that need to be fetched. */ + getExternalFilePaths: () => StringVector; + /** Loads external file data for an Image node matching the given path. Returns true on success. */ + loadFileData: (filePath: string, fileData: Uint8Array) => boolean; + /** + * Attaches a host-decoded native image to Image nodes matching the given path under a + * specific quality tier. The image is queued for GPU texture upload; actual texImage2D + * happens during the next draw(). The qualityRaw argument is the integer value of + * ImageQuality (Thumbnail = 0, Full = 1). + */ + attachNativeImage: (filePath: string, nativeImage: any, qualityRaw: number) => boolean; + /** + * Provides the original (full-resolution) pixel dimensions of an external image so that + * the new-format PAGX ImagePattern matrix can be corrected when the host attaches a + * downscaled variant. New-format PAGX bakes the pattern matrix in the original image's + * pixel coordinate system; without this hint the SDK falls back to the attached image's + * own dimensions, which produces a misaligned fill. Call right after parsePAGX() and + * before buildLayers() / attachNativeImage(). The width/height must be > 0; parsePAGX() + * clears the entire table. + */ + setImageOriginalSize: (filePath: string, width: number, height: number) => void; + /** + * Registers a JavaScript object whose onTextureRequest / onTextureEvict methods receive + * backend-texture lifecycle events. Pass null/undefined to clear a previously registered + * handler. The handler survives across parsePAGX() calls. + */ + setTextureEventHandler: (handler: any) => void; + /** + * Returns true when externalTexturesTotalBytes has crossed fullBudgetHardCap. Hosts running + * a progressive thumbnail-to-full upgrade flow should consult this at the top of every + * upgrade pass and skip new full-quality uploads while it returns true. Reactive responses + * to onTextureRequest are still safe (each one is a 1:1 replacement for an evicted entry, + * so totalBytes stays net-flat). The flag clears automatically on the next draw whose + * eviction sweep reduces totalBytes back below the hard cap. + */ + isFullBudgetSaturated: () => boolean; + /** + * Returns root-space (canvas-pixel) bounds for each filePath in the provided list. Values + * are either { unionBounds, largestBounds } objects or null when the filePath is not + * referenced by any layer. Must be called after buildLayers(). The first call triggers + * lazy localBounds computation inside tgfx and is noticeably heavier than later calls. + */ + getImageBounds: (filePathList: string[]) => Record; + /** + * Returns image metadata (original dimensions + per-usage node dimensions and scaleMode) + * for every externally referenced image in the document. JS callers use this to pick the + * right thumbnail size and compute display scale without having to re-parse the PAGX XML. + * Must be called after parsePAGX(). + */ + getImageMetadata: () => ImageMetadataEntry[]; + /** Builds the layer tree from the previously parsed document. */ + buildLayers: () => void; + /** Updates the canvas size and recreates the surface. */ + updateSize: (width: number, height: number) => void; + /** Updates the zoom scale and content offset for the display list. */ + updateZoomScaleAndOffset: (zoom: number, offsetX: number, offsetY: number) => void; + /** Notifies that zoom gesture has ended for adaptive tile refinement. */ + onZoomEnd: () => void; + /** Renders the current frame to the canvas. Returns true if rendering succeeds. */ + draw: () => boolean; + /** Returns true if the first frame has been rendered successfully. */ + firstFrameRendered: () => boolean; + /** Drop intermediate snapshots captured before gesture-state init, reset first-frame flag. */ + resetForFreshCapture: () => void; + /** Returns the width of the PAGX content in content pixels. */ + contentWidth: () => number; + /** Returns the height of the PAGX content in content pixels. */ + contentHeight: () => number; + /** Overrides bounds origin read from the PAGX file. Values can be negative. */ + setBoundsOrigin: (x: number, y: number) => void; + /** Toggles gesture-freeze rendering; true during active pan/zoom, false otherwise. */ + setGestureActive: (active: boolean) => void; + /** Toggles the fitSnapshot fast path. Default true. Disable to force full render every frame. */ + setSnapshotEnabled: (enabled: boolean) => void; + /** Returns content transform parameters for mapping cocraft coordinates to canvas positions. */ + getContentTransform: () => ContentTransform; + /** Looks up a node by ID and returns its position relative to the canvas. */ + getNodePosition: (nodeId: string) => NodePosition; + /** Releases the native C++ object. */ + delete: () => void; +} + +/** + * Emscripten register_vector wrapper, used to access string vectors returned from C++. + */ +export interface StringVector { + /** Returns the number of elements. */ + size: () => number; + /** Returns the element at the given index. */ + get: (index: number) => string; + /** Releases the underlying C++ object. Must be called after use to avoid memory leaks. */ + delete: () => void; +} + +/** + * Content transform parameters for mapping cocraft canvas coordinates to canvas pixel positions. + * Returned by View.getContentTransform(). These values are static per file load and canvas size. + */ +export interface ContentTransform { + /** X coordinate of the PAGX content bounds origin in cocraft canvas coordinates. */ + boundsOriginX: number; + /** Y coordinate of the PAGX content bounds origin in cocraft canvas coordinates. */ + boundsOriginY: number; + /** Scale factor applied to fit PAGX content into the canvas (contain mode). */ + fitScale: number; + /** Horizontal pixel offset for centering the scaled content in the canvas. */ + centerOffsetX: number; + /** Vertical pixel offset for centering the scaled content in the canvas. */ + centerOffsetY: number; +} + +/** + * Result of looking up a node's position relative to the canvas. + * Returned by View.getNodePosition(). + */ +export interface NodePosition { + /** Whether the node was found and has valid position data. */ + found: boolean; + /** The x coordinate of the node relative to the canvas (0 if not found). */ + x: number; + /** The y coordinate of the node relative to the canvas (0 if not found). */ + y: number; +} + +/** + * Root-space axis-aligned rectangle. Coordinates are in canvas pixels, already accounting for + * the content fit scale and centering offset applied to contentLayer. + */ +export interface ImageRect { + x: number; + y: number; + w: number; + h: number; +} + +/** + * Bounds result for a single filePath returned by View.getImageBounds(). + */ +export interface ImageBoundsEntry { + /** Union of the bounds of every layer referencing the filePath. Used for viewport tests. */ + unionBounds: ImageRect; + /** Bounds of the single layer with the largest display area. Used for focus distance. */ + largestBounds: ImageRect; +} + +/** + * Per-usage ImagePattern parameters surfaced to JS by View.getImageMetadata(). + */ +export interface ImageUsage { + /** Target node width in design-space pixels (may be 0 when the exporter omitted it). */ + nodeWidth: number; + /** Target node height in design-space pixels (may be 0 when the exporter omitted it). */ + nodeHeight: number; + /** ImageScaleMode encoded as the PAGX integer: 0=FILL, 1=FIT, 2=STRETCH, 3=TILE. */ + scaleMode: number; + /** Scale factor used by TILE patterns (ignored for non-TILE modes). */ + scaleFactor: number; +} + +/** + * Metadata entry returned for each externally referenced image by View.getImageMetadata(). + */ +export interface ImageMetadataEntry { + /** External file path, used as the key when calling attachNativeImage() etc. */ + filePath: string; + /** Original image width in source pixels (0 when the exporter omitted it). */ + origWidth: number; + /** Original image height in source pixels (0 when the exporter omitted it). */ + origHeight: number; + /** Every ImagePattern that references this filePath, one entry per usage. */ + usages: ImageUsage[]; +} + +/** + * PAGX WebAssembly module interface extending TGFX with PAGXView support. + */ +export interface PAGX extends TGFX { + /** Factory for creating native PAGXView instances. */ + PAGXView: { + /** Creates a PAGXView instance with the given canvas dimensions. */ + MakeFrom: (width: number, height: number) => PAGXViewNative | null; + }; + /** + * High-level View class bound onto the module by binding(); only available after + * PAGXInit() resolves. Use View.init(module, canvas, options?) to create an instance. + */ + View: typeof View; + VectorString: any; + module: PAGX; +} + diff --git a/pagx/wechat/ts/wx-system-info.ts b/pagx/wechat/ts/wx-system-info.ts new file mode 100644 index 0000000000..37b5ed0058 --- /dev/null +++ b/pagx/wechat/ts/wx-system-info.ts @@ -0,0 +1,207 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * WeChat MiniProgram system info utility. + * Adapts to different base library versions for deprecated APIs: + * - wx.getSystemInfoSync is deprecated since base library 2.20.1. + * - wx.getDeviceInfo().benchmarkLevel and modelLevel are deprecated since + * 3.4.5; use wx.getDeviceBenchmarkInfo() instead. + * - wx.getDeviceBenchmarkInfo is async (callback-based), the rest are sync. + */ + +declare const wx: any; + +const MODEL_LEVEL_UNKNOWN = 0; +const MODEL_LEVEL_HIGH = 1; +const MODEL_LEVEL_MID = 2; +const MODEL_LEVEL_LOW = 3; + +function compareVersion(v1: string, v2: string): number { + const parts1 = v1.split('.').map(Number); + const parts2 = v2.split('.').map(Number); + const len = Math.max(parts1.length, parts2.length); + for (let i = 0; i < len; i++) { + const n1 = parts1[i] || 0; + const n2 = parts2[i] || 0; + if (n1 > n2) return 1; + if (n1 < n2) return -1; + } + return 0; +} + +function gte(v1: string, v2: string): boolean { + return compareVersion(v1, v2) >= 0; +} + +function getSDKVersion(): string { + try { + const appBaseInfo = wx.getAppBaseInfo(); + if (appBaseInfo && appBaseInfo.SDKVersion) { + return appBaseInfo.SDKVersion; + } + } catch (_) { + // ignore + } + try { + const info = wx.getSystemInfoSync(); + return info.SDKVersion || ''; + } catch (_) { + return ''; + } +} + +function calculateModelLevel(benchmarkLevel: number, platform: string): number { + if (benchmarkLevel < 0) { + return MODEL_LEVEL_UNKNOWN; + } + if (platform === 'android' || platform === 'Android') { + if (benchmarkLevel >= 30) return MODEL_LEVEL_HIGH; + if (benchmarkLevel >= 23) return MODEL_LEVEL_MID; + return MODEL_LEVEL_LOW; + } + if (platform === 'ios' || platform === 'iOS') { + if (benchmarkLevel >= 36) return MODEL_LEVEL_HIGH; + if (benchmarkLevel >= 30) return MODEL_LEVEL_MID; + return MODEL_LEVEL_LOW; + } + return MODEL_LEVEL_UNKNOWN; +} + +function getDeviceBenchmarkInfoNativeAsync(): Promise<{ + benchmarkLevel: number; + modelLevel: number; +} | null> { + return new Promise((resolve) => { + try { + wx.getDeviceBenchmarkInfo({ + success(res: any) { + resolve({ + benchmarkLevel: res.benchmarkLevel, + modelLevel: res.modelLevel, + }); + }, + fail() { + resolve(null); + }, + }); + } catch (_) { + resolve(null); + } + }); +} + +async function getDeviceBenchmarkInfo( + sdkVersion: string, + platform: string +): Promise<{ benchmarkLevel: number; modelLevel: number }> { + if (gte(sdkVersion, '3.4.5') && typeof wx.getDeviceBenchmarkInfo === 'function') { + const result = await getDeviceBenchmarkInfoNativeAsync(); + if (result) { + return result; + } + } + let benchmarkLevel = -1; + try { + const deviceInfo = wx.getDeviceInfo(); + if (deviceInfo && deviceInfo.benchmarkLevel !== undefined) { + benchmarkLevel = deviceInfo.benchmarkLevel; + } + } catch (_) { + // ignore and fall through + } + if (benchmarkLevel < 0) { + try { + const info = wx.getSystemInfoSync(); + if (info.benchmarkLevel !== undefined) { + benchmarkLevel = info.benchmarkLevel; + } + } catch (_) { + // ignore + } + } + return { + benchmarkLevel, + modelLevel: calculateModelLevel(benchmarkLevel, platform), + }; +} + +async function getSystemInfoNew(sdkVersion: string) { + const appBaseInfo = wx.getAppBaseInfo(); + const deviceInfo = wx.getDeviceInfo(); + const windowInfo = wx.getWindowInfo(); + const systemSetting = wx.getSystemSetting(); + + const platform = deviceInfo.platform || ''; + let system = platform; + if (platform === 'ios' || platform === 'android') { + system = platform.charAt(0).toUpperCase() + platform.slice(1); + } + + const benchInfo = await getDeviceBenchmarkInfo(sdkVersion, platform); + + return { + system, + platform, + SDKVersion: sdkVersion, + pixelRatio: windowInfo.pixelRatio || deviceInfo.pixelRatio || 1, + screenWidth: windowInfo.screenWidth || deviceInfo.screenWidth || 0, + screenHeight: windowInfo.screenHeight || deviceInfo.screenHeight || 0, + windowWidth: windowInfo.windowWidth || 0, + windowHeight: windowInfo.windowHeight || 0, + statusBarHeight: windowInfo.statusBarHeight || 0, + safeArea: windowInfo.safeArea, + language: appBaseInfo.language || systemSetting.language || 'zh_CN', + version: appBaseInfo.version || '', + benchmarkLevel: benchInfo.benchmarkLevel, + modelLevel: benchInfo.modelLevel, + }; +} + +async function getSystemInfoLegacy() { + const info = wx.getSystemInfoSync(); + const benchInfo = await getDeviceBenchmarkInfo(info.SDKVersion || '', info.platform || ''); + return { + system: info.system || '', + platform: info.platform || '', + SDKVersion: info.SDKVersion || '', + pixelRatio: info.pixelRatio || 1, + screenWidth: info.screenWidth || 0, + screenHeight: info.screenHeight || 0, + windowWidth: info.windowWidth || 0, + windowHeight: info.windowHeight || 0, + statusBarHeight: info.statusBarHeight || 0, + safeArea: info.safeArea, + language: info.language || 'zh_CN', + version: info.version || '', + benchmarkLevel: benchInfo.benchmarkLevel, + modelLevel: benchInfo.modelLevel, + }; +} + +export async function getSystemInfo() { + const sdkVersion = getSDKVersion(); + if (sdkVersion && gte(sdkVersion, '2.20.1')) { + return getSystemInfoNew(sdkVersion); + } + return getSystemInfoLegacy(); +} + +export function getSDKInfo(): string { + return getSDKVersion(); +} diff --git a/pagx/wechat/tsconfig.json b/pagx/wechat/tsconfig.json new file mode 100644 index 0000000000..ae1b1a5d10 --- /dev/null +++ b/pagx/wechat/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "lib": [ + "ES5", + "ES6", + "DOM", + "DOM.Iterable" + ], + "target": "ES2020", + "module": "ES2020", + "allowJs": true, + "sourceMap": true, + "esModuleInterop": true, + "moduleResolution": "Node", + "experimentalDecorators": true, + "strict": true, + "outDir": "./wasm-mt", + "baseUrl": ".", + "resolveJsonModule": true, + "paths": { + "@tgfx/*": [ + "../../third_party/tgfx/web/src/*" + ], + } + }, + "include": ["*.ts", "ts/**/*.ts"] +} \ No newline at end of file diff --git a/pagx/wechat/tsconfig.type.json b/pagx/wechat/tsconfig.type.json new file mode 100644 index 0000000000..747a142c30 --- /dev/null +++ b/pagx/wechat/tsconfig.type.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "lib": ["ES5", "ES6", "DOM", "DOM.Iterable"], + "target": "ES2020", + "declaration": true, + "declarationDir": "./types", + "emitDeclarationOnly": true, + "removeComments": false, + "skipLibCheck": true, + "experimentalDecorators": true, + "moduleResolution": "Node", + "baseUrl": ".", + "resolveJsonModule": true, + "paths": { + "@tgfx/*": [ + "../../third_party/tgfx/web/src/*" + ] + } + }, + "include": ["ts/pagx.ts"] +} diff --git a/pagx/wechat/wx_demo/.gitignore b/pagx/wechat/wx_demo/.gitignore new file mode 100644 index 0000000000..4d8437e5a9 --- /dev/null +++ b/pagx/wechat/wx_demo/.gitignore @@ -0,0 +1,6 @@ +utils/* +# performance-monitor.js is hand-written demo source (no build producer), unlike the +# rollup-generated utils such as gesture-manager.js; keep it tracked so a fresh clone builds. +!utils/performance-monitor.js +pages/system-info-test/* +pages/image-decode-poc/* diff --git a/pagx/wechat/wx_demo/BUILD.md b/pagx/wechat/wx_demo/BUILD.md new file mode 100644 index 0000000000..c1d41ad73f --- /dev/null +++ b/pagx/wechat/wx_demo/BUILD.md @@ -0,0 +1,162 @@ +# 微信小程序构建指南 + +## 🚀 快速开始 + +### 一键构建 + +```bash +cd pagx/wechat +npm run build:wechat +``` + +这个命令会: +1. 构建 TGFX 库(`npm run build:tgfx`) +2. 编译单线程 WASM(`node script/cmake.wx.js`) +3. 打包到 `wechat/wasm/`(`node script/rollup.wx.js`) + +--- + +## ⚠️ 重要说明 + +### 为什么需要单独构建? + +微信小程序**不支持 SharedArrayBuffer**,必须使用**单线程 WASM**。 + +| 特性 | Web 版本 | 微信版本 | +|------|---------|---------| +| 多线程 | ✅ | ❌ | +| SharedArrayBuffer | ✅ | ❌ | +| 构建命令 | `npm run build` | `npm run build:wechat` | +| 输出目录 | `wasm-mt/` | `wechat/wasm/` | + +--- + +## 📦 构建流程 + +### Step 1: 配置环境 + +```bash +# 确保 Emscripten 已安装 +source $EMSDK/emsdk_env.sh + +# 验证版本 +emcc --version +``` + +### Step 2: 运行构建 + +```bash +cd pagx/wechat +npm run build:wechat +``` + +### Step 3: 验证输出 + +```bash +ls -lh wechat/wasm/ +``` + +应该看到: +- `pagx.wasm` (~1.8 MB) +- `pagx.js` (~150-200 KB) + +--- + +## 🎯 成功标志 + +``` +✅ Build artifacts: + - pagx.wasm: 1.85 MB + - pagx.js: 168 KB + - Location: build-wechat + +✅ WeChat Miniprogram package completed! +``` + +--- + +## 🔧 故障排查 + +### 问题 1: 编译错误 + +``` +WebTypeface.cpp:119:35: error +``` + +**原因:** TGFX 源码错误 +**解决:** 修复源码后重新构建 + +--- + +### 问题 2: 找不到 WASM + +``` +❌ pagx.wasm not found +``` + +**检查:** +```bash +# 查看构建目录 +ls build-wechat/ + +# 如果不存在,重新构建 +rm -rf build-wechat +npm run build:wechat +``` + +--- + +### 问题 3: 微信小程序运行失败 + +``` +SharedArrayBuffer is not defined +``` + +**原因:** 使用了多线程版本 +**解决:** 确保运行 `npm run build:wechat` + +--- + +## 🧪 测试验证 + +### 1. 导入微信开发者工具 + +- 项目目录:`pagx/wechat` +- AppID:`touristappid` + +### 2. 检查文件加载 + +控制台应显示: +``` +✓ WASM loaded successfully +✓ PAGXViewer initialized +``` + +### 3. 测试功能 + +- [ ] PAGX 文件加载成功 +- [ ] Canvas 显示内容(非黑屏) +- [ ] 拖动移动功能正常 +- [ ] 重置功能正常 +- [ ] 无控制台错误 + +--- + +## 📚 相关文档 + +- **完整设计方案:** `.codebuddy/designs/pagx_wechat_design.md` +- **构建原理:** `.codebuddy/designs/pagx_wechat_build.md` +- **快速启动:** `QUICKSTART.md` + +--- + +## 🎉 下一步 + +构建成功后: + +1. 打开微信开发者工具 +2. 导入项目:`pagx/wechat/wx_demo` +3. 编译运行 +4. 测试功能 + +Good luck! 🚀 diff --git a/pagx/wechat/wx_demo/README.md b/pagx/wechat/wx_demo/README.md new file mode 100644 index 0000000000..49c8dff05f --- /dev/null +++ b/pagx/wechat/wx_demo/README.md @@ -0,0 +1,171 @@ +# PAGX 微信小程序预览工具 + +基于 TGFX 和 WebAssembly 的 PAGX 文件预览工具微信小程序版本。 + +## 快速开始 + +### 1. 构建项目 + +在 `pagx/wechat` 目录下执行: + +```bash +npm run build:wechat +``` + +### 2. 打开微信开发者工具 + +1. 打开「微信开发者工具」 +2. 选择「导入项目」 +3. 项目目录选择:`pagx/wechat/wx_demo` +4. AppID 设置为 `touristappid`(测试)或你的 AppID + +### 3. 编译运行 + +点击「编译」按钮,查看预览效果。 + +## 项目结构 + +``` +wechat/ +├── app.json # 小程序配置 +├── app.js # 小程序入口 +├── app.wxss # 全局样式 +├── project.config.json # 工具配置 +├── pages/ +│ └── viewer/ # 预览页面 +│ ├── viewer.js # 页面逻辑 +│ ├── viewer.wxml # 页面结构 +│ ├── viewer.wxss # 页面样式 +│ └── viewer.json # 页面配置 +└── wasm/ # WASM 文件(构建生成) + ├── pagx.wasm # WASM 模块 + └── pagx.js # WASM 加载器(已适配微信环境) +``` + +## 功能说明 + +### 当前功能(MVP) + +- ✅ 从 CDN 加载 PAGX 文件 +- ✅ 在 WebGL Canvas 上渲染 +- ✅ 触摸拖动手势 +- ✅ 重置按钮 + +### 后续计划 + +- [ ] 多个示例文件切换 +- [ ] 缩放手势 +- [ ] 播放控制(暂停/播放/进度条) +- [ ] 截图分享 + +## 配置说明 + +### 修改示例文件 URL + +编辑 `pages/viewer/viewer.js`: + +```javascript +// 修改这个 URL 为你的 CDN 地址 +const SAMPLE_URL = 'https://your-cdn.com/samples/your-file.pagx'; +``` + +### 配置合法域名 + +如果使用自己的 CDN,需要在微信小程序后台配置: + +1. 登录 [微信小程序后台](https://mp.weixin.qq.com/) +2. 进入「开发」-「开发管理」-「开发设置」 +3. 在「服务器域名」中添加 `downloadFile` 合法域名 +4. 示例:`https://your-cdn.com` + +## 常见问题 + +### 1. 报错:WebAssembly is not defined + +**原因**:微信小程序使用 `WXWebAssembly` 而非标准 `WebAssembly`。 + +**解决**:确保已运行 `npm run build:wechat:quick` 或 `npm run build:wechat`,这些命令会自动适配微信环境。 + +### 2. 报错:Failed to load WASM module + +**可能原因**: +- WASM 文件不存在或路径错误 +- 使用了多线程版本的 WASM(小程序不支持) + +**解决**: +- 检查 `wasm/` 目录下是否有 `pagx.wasm` 和 `pagx.js` +- 确保使用单线程版本构建(`-a wasm`) + +### 3. 下载文件失败 + +**可能原因**: +- CDN URL 不可访问 +- 未配置合法域名 +- CDN 未启用 HTTPS + +**解决**: +- 检查 CDN URL 是否正确 +- 在小程序后台配置 `downloadFile` 合法域名 +- 确保 CDN 支持 HTTPS 和 CORS + +### 4. 渲染黑屏 + +**可能原因**: +- WebGL 初始化失败 +- PAGX 文件格式错误 +- Canvas 尺寸问题 + +**解决**: +- 检查基础库版本(需要 2.15.0+) +- 验证 PAGX 文件是否正常 +- 查看控制台错误日志 + +## 技术限制 + +### 微信小程序限制 + +1. **不支持多线程 WASM**:必须使用单线程版本(`-a wasm`) +2. **包体积限制**:单个分包不超过 2MB,总包不超过 20MB +3. **域名白名单**:需要在后台配置合法域名 +4. **基础库版本**:最低 2.15.0(支持 `WXWebAssembly`) + +### 性能限制 + +1. **内存限制**:iOS 约 300MB,Android 约 500MB +2. **渲染性能**:取决于设备性能 +3. **文件大小**:建议 PAGX 文件不超过 10MB + +## 开发调试 + +### 启用调试信息 + +在 `pages/viewer/viewer.js` 中,已包含 `console.log` 输出: + +```javascript +console.log('Starting initialization...'); +console.log('WASM module loaded'); +console.log('PAGXView created successfully'); +// ... +``` + +在微信开发者工具的「控制台」中查看日志。 + +### 性能监控 + +使用微信开发者工具的「性能」面板: +1. 点击「性能」标签 +2. 选择「渲染性能」 +3. 查看 FPS、内存使用情况 + +## 参考资料 + +- [微信小程序官方文档](https://developers.weixin.qq.com/miniprogram/dev/framework/) +- [WXWebAssembly API](https://developers.weixin.qq.com/miniprogram/dev/framework/performance/wasm.html) +- [TGFX 项目](https://github.com/Tencent/tgfx) +- [PAG 官网](https://pag.io) + +## 许可证 + +BSD 3-Clause License + +Copyright (C) 2026 Tencent. All rights reserved. diff --git a/pagx/wechat/wx_demo/app.js b/pagx/wechat/wx_demo/app.js new file mode 100644 index 0000000000..b1ae94a88f --- /dev/null +++ b/pagx/wechat/wx_demo/app.js @@ -0,0 +1,9 @@ +/** + * Copyright (C) 2026 Tencent. All Rights Reserved. + */ + +App({ + onLaunch() { + console.log('PAGX Viewer MVP launched'); + } +}); diff --git a/pagx/wechat/wx_demo/app.json b/pagx/wechat/wx_demo/app.json new file mode 100644 index 0000000000..6e2e62839e --- /dev/null +++ b/pagx/wechat/wx_demo/app.json @@ -0,0 +1,12 @@ +{ + "pages": [ + "pages/viewer/viewer" + ], + "window": { + "navigationBarTitleText": "PAGX Viewer", + "navigationBarBackgroundColor": "#1a1a1a", + "navigationBarTextStyle": "white", + "backgroundColor": "#2a2a2a" + }, + "sitemapLocation": "sitemap.json" +} diff --git a/pagx/wechat/wx_demo/pages/viewer/comment.wxs b/pagx/wechat/wx_demo/pages/viewer/comment.wxs new file mode 100644 index 0000000000..5e23125b46 --- /dev/null +++ b/pagx/wechat/wx_demo/pages/viewer/comment.wxs @@ -0,0 +1,26 @@ +/** + * Copyright (C) 2026 Tencent. All Rights Reserved. + * WXS module for comment overlay positioning. + * Runs on the render thread to avoid setData latency during zoom/pan gestures. + */ + +/** + * Called on each comment-pin node when commentTransform changes. + * Reads baseX/baseY from the node's dataset and computes screen position. + */ +function onTransformChanged(newVal, oldVal, ownerInstance, instance) { + if (!newVal || !newVal.zoom) { + return; + } + var dataset = instance.getDataset(); + var screenX = (dataset.basex * newVal.zoom + newVal.panOffsetX) / newVal.dpr; + var screenY = (dataset.basey * newVal.zoom + newVal.panOffsetY) / newVal.dpr; + instance.setStyle({ + left: screenX + 'px', + top: screenY + 'px' + }); +} + +module.exports = { + onTransformChanged: onTransformChanged +}; diff --git a/pagx/wechat/wx_demo/pages/viewer/viewer.js b/pagx/wechat/wx_demo/pages/viewer/viewer.js new file mode 100644 index 0000000000..65cde29f67 --- /dev/null +++ b/pagx/wechat/wx_demo/pages/viewer/viewer.js @@ -0,0 +1,534 @@ +/** + * Copyright (C) 2026 Tencent. All Rights Reserved. + * PAGX Viewer MVP for WeChat Miniprogram + */ + +import { + PAGXInit, +} from '../../utils/pagx-viewer'; +import { WXGestureManager } from '../../utils/gesture-manager'; +import { PerformanceMonitor } from '../../utils/performance-monitor'; + +// Font CDN URLs +const FONT_URL = 'https://pag.qq.com/wx_pagx_demo/fonts/NotoSansSC-Regular.otf'; +const EMOJI_FONT_URL = 'https://pag.qq.com/wx_pagx_demo/fonts/NotoColorEmoji.ttf'; + +// PAGX sample files configuration +const SAMPLE_FILES = [ + { + name: 'page_0-1', + url: 'https://pag.qq.com/wx_pagx_demo/page_0-1.pagx' + }, + { + name: 'page_0-0', + url: 'https://pag.qq.com/wx_pagx_demo/page_0-0.pagx' + }, + { + name: 'jianbian_0', + url: 'https://pag.qq.com/wx_pagx_demo/jianbian_0.pagx' + }, + { + name: 'ColorPicker', + url: 'https://pag.qq.com/wx_pagx_demo/ColorPicker.pagx' + }, + { + name: 'Baseline', + url: 'https://pag.qq.com/wx_pagx_demo/Baseline.pagx' + }, + { + name: 'Overview', + url: 'https://pag.qq.com/wx_pagx_demo/Overview.pagx' + }, + { + name: 'UIkit', + url: 'https://pag.qq.com/wx_pagx_demo/UIkit.pagx' + }, +]; + +Page({ + data: { + loading: true, + samples: SAMPLE_FILES, + sampleNames: SAMPLE_FILES.map(item => item.name), + currentIndex: 0, + loadingFile: false, + + // Performance monitoring + showPerf: false, + perfStats: { + fps: 0, + avgFPS: 0, + frameTime: 0, + dropRate: 0, + smoothness: 0, + quality: 'N/A' + }, + + // Comment overlays demo + showComments: false, + commentPins: [], // Fixed base coords for WXS: [{id, text, color, baseX, baseY}] + commentTransform: null // Dynamic transform for WXS: {zoom, panOffsetX, panOffsetY, dpr} + }, + + // State + View: null, + module: null, + canvas: null, + gestureManager: null, + perfMonitor: null, + dpr: 2, + gestureJustStarted: false, + fontData: null, + emojiFontData: null, + + async onLoad(options) { + try { + // Get device pixel ratio for converting logical pixels to physical pixels + this.dpr = wx.getSystemInfoSync().pixelRatio || 2; + + // Create gesture manager + this.gestureManager = new WXGestureManager(); + + // Create performance monitor + this.perfMonitor = new PerformanceMonitor(); + this.perfMonitor.onStatsUpdate = (stats) => { + this.updatePerfStats(stats); + }; + + // Support custom file index from query params + if (options && options.index) { + const index = parseInt(options.index, 10); + if (index >= 0 && index < SAMPLE_FILES.length) { + this.setData({ currentIndex: index }); + } + } + + // Load fonts and initialize viewer in parallel + await Promise.all([ + this.loadFonts(), + this.initializeViewer() + ]); + + await this.loadCurrentFile(); + + // Initialize gesture manager with canvas (physical pixels) + const initState = this.gestureManager.init( + this.canvas.width, // physical pixels (rect.width * dpr) + this.canvas.height // physical pixels (rect.height * dpr) + ); + if (!initState) { + throw new Error('Failed to initialize gesture manager'); + } + + // Apply initial state to ensure C++ side is synchronized + this.applyGestureState(initState); + + // Setup performance monitoring callbacks + this.View.setRenderCallbacks(null, () => { + if (this.perfMonitor && this.perfMonitor.enabled) { + this.perfMonitor.recordFrame(); + + // Mark first frame after gesture start + if (this.gestureJustStarted) { + this.perfMonitor.onGestureFirstFrame(); + this.gestureJustStarted = false; + } + } + }); + + this.View.startRendering(); + this.setData({ loading: false }); + } catch (error) { + console.error('Initialization failed:', error); + wx.showModal({ + title: 'Error', + content: 'Failed to initialize viewer: ' + error.message, + showCancel: false + }); + } + }, + + onUnload() { + if (this.gestureManager) { + this.gestureManager.destroy(); + this.gestureManager = null; + } + + if (this.View) { + this.View.destroy(); + this.View = null; + } + }, + + async initializeViewer() { + // Load WASM module + this.module = await PAGXInit({ + locateFile: (file) => '/utils/' + file + }); + + // Get canvas instance and size + return new Promise((resolve, reject) => { + const query = wx.createSelectorQuery(); + query.select('#pagx-canvas') + .node() + .exec(async (res) => { + try { + if (!res || !res[0]) { + throw new Error('Failed to get canvas node'); + } + + this.canvas = res[0].node; + + // Get canvas display size + const query2 = wx.createSelectorQuery(); + query2.select('#pagx-canvas') + .boundingClientRect() + .exec(async (rectRes) => { + try { + if (!rectRes || !rectRes[0]) { + throw new Error('Failed to get canvas rect'); + } + + const rect = rectRes[0]; + + // Set canvas physical pixel size based on display size and device pixel ratio + // This ensures sharp rendering on high-DPI displays + this.canvas.width = Math.floor(rect.width * this.dpr); + this.canvas.height = Math.floor(rect.height * this.dpr); + + // Create View + this.View = await this.module.View.init(this.module, this.canvas, { + autoRender: false + }); + + if (!this.View) { + throw new Error('Failed to create View'); + } + + resolve(); + } catch (error) { + reject(error); + } + }); + } catch (error) { + reject(error); + } + }); + }); + }, + + async loadCurrentFile() { + const { currentIndex } = this.data; + const sample = SAMPLE_FILES[currentIndex]; + + // Download from CDN + const data = await this.downloadFile(sample.url); + + // Register fonts before loading PAGX to ensure fonts are available during layout + this.registerFontsToView(); + + // Load into View + this.View.loadPAGX(data); + + // TODO: Remove after PAGX file embeds bounds-origin data. + // Manually set boundsOrigin for comment positioning verification. + this.View.setBoundsOrigin(-900, -193); + + // Re-initialize gesture manager with new canvas dimensions + // NOTE: init() automatically resets all transforms and returns the reset state + const resetState = this.gestureManager.init( + this.canvas.width, // physical pixels + this.canvas.height // physical pixels + ); + + // Apply reset state to synchronize C++ side + if (resetState) { + this.applyGestureState(resetState); + } + + // Re-initialize comment overlays for new content + if (this.data.showComments) { + this.initCommentOverlays(); + } + }, + + async switchFile(index) { + if (index === this.data.currentIndex || !this.View) { + return; + } + + try { + // Update UI (non-blocking) and load file (parallel) + this.setData({ + loadingFile: true, + currentIndex: index + }); + + // Load new file immediately (don't wait for UI render) + await this.loadCurrentFile(); + + wx.showToast({ + title: 'Loaded', + icon: 'success', + duration: 1000 + }); + } catch (error) { + console.error('Failed to switch file:', error); + wx.showModal({ + title: 'Error', + content: 'Failed to load file: ' + error.message, + showCancel: false + }); + } finally { + this.setData({ loadingFile: false }); + } + }, + + async loadFonts() { + const [fontData, emojiFontData] = await Promise.all([ + this.downloadFontFile(FONT_URL), + this.downloadFontFile(EMOJI_FONT_URL) + ]); + this.fontData = fontData; + this.emojiFontData = emojiFontData; + }, + + downloadFontFile(url) { + return new Promise((resolve) => { + wx.request({ + url: url, + responseType: 'arraybuffer', + success: (res) => { + if (res.statusCode === 200) { + resolve(new Uint8Array(res.data)); + } else { + console.warn(`Font download failed: HTTP ${res.statusCode} for ${url}`); + resolve(null); + } + }, + fail: (err) => { + console.warn(`Font download failed: ${err.errMsg} for ${url}`); + resolve(null); + } + }); + }); + }, + + registerFontsToView() { + if (!this.View) { + return; + } + const fontData = this.fontData || new Uint8Array(0); + const emojiFontData = this.emojiFontData || new Uint8Array(0); + this.View.registerFonts(fontData, emojiFontData); + }, + + downloadFile(url) { + return new Promise((resolve, reject) => { + wx.showLoading({ title: 'Downloading...' }); + + wx.request({ + url: url, + responseType: 'arraybuffer', + success: (res) => { + wx.hideLoading(); + if (res.statusCode === 200) { + const uint8Array = new Uint8Array(res.data); + resolve(uint8Array); + } else { + reject(new Error(`HTTP ${res.statusCode}`)); + } + }, + fail: (err) => { + wx.hideLoading(); + reject(new Error(`Download failed: ${err.errMsg}`)); + } + }); + }); + }, + + resetTransform() { + if (!this.gestureManager || !this.View) { + return; + } + + const state = this.gestureManager.reset(); + this.applyGestureState(state); + }, + + applyGestureState(state) { + if (!state || state.action === 'none') { + return; + } + + if (!this.View) { + return; + } + + // Always update C++ side immediately for smooth rendering + try { + this.View.updateZoomScaleAndOffset(state.zoom, state.offsetX, state.offsetY); + } catch (error) { + // Silently ignore errors + } + + // Update comment transform for WXS render-thread positioning + if (this.data.showComments) { + this.setData({ + commentTransform: { + zoom: state.zoom, + panOffsetX: state.offsetX, + panOffsetY: state.offsetY, + dpr: this.dpr + } + }); + } + }, + + // Touch Events + onTouchStart(e) { + if (!this.gestureManager) return; + + // Notify performance monitor + if (this.perfMonitor && this.perfMonitor.enabled) { + this.perfMonitor.onGestureStart(); + this.gestureJustStarted = true; + } + + const state = this.gestureManager.onTouchStart(e.touches); + this.applyGestureState(state); + }, + + onTouchMove(e) { + if (!this.gestureManager) return; + // Pass dpr to convert touch coordinates (logical pixels) to physical pixels + const state = this.gestureManager.onTouchMove(e.touches, this.dpr); + this.applyGestureState(state); + }, + + onTouchEnd(e) { + if (!this.gestureManager) return; + // IMPORTANT: Pass e.touches (remaining touches), not e.changedTouches (ended touches) + const state = this.gestureManager.onTouchEnd(e.touches); + this.applyGestureState(state); + + // Notify performance monitor + if (this.perfMonitor && this.perfMonitor.enabled && e.touches.length === 0) { + this.perfMonitor.onGestureEnd(); + this.gestureJustStarted = false; + } + }, + + // Reset Button + onReset() { + // Prevent multiple rapid clicks + if (this.data.loadingFile) { + return; + } + + this.resetTransform(); + wx.showToast({ + title: 'Reset', + icon: 'success', + duration: 1000 + }); + }, + + // Picker Selection + onPickerChange(e) { + const index = parseInt(e.detail.value, 10); + if (index !== this.data.currentIndex) { + this.switchFile(index); + } + }, + + // Performance Monitoring + togglePerfMonitor() { + const newShowPerf = !this.data.showPerf; + this.setData({ showPerf: newShowPerf }); + + if (newShowPerf && this.perfMonitor) { + this.perfMonitor.reset(); + this.perfMonitor.start(); + } else if (this.perfMonitor) { + this.perfMonitor.stop(); + this.perfMonitor.logStats(); + } + }, + + updatePerfStats(stats) { + this.setData({ + perfStats: { + fps: stats.currentFPS, + avgFPS: stats.averageFPS, + frameTime: stats.averageFrameTime, + dropRate: stats.droppedFrameRate, + smoothness: stats.smoothness, + quality: this.perfMonitor.getSummary().quality + } + }); + }, + + // ========== Comment Overlay Demo ========== + + // Comment data in cocraft canvas coordinates. + // boundsOrigin: (-900, -193) + getComments() { + return [ + { id: 1, cocraftX: -381, cocraftY: 69, text: 'Comment 1', color: '#ff4d4f' }, + { id: 2, cocraftX: -398, cocraftY: 269, text: 'Comment 2', color: '#1890ff' }, + { id: 3, cocraftX: -191, cocraftY: 409, text: 'Comment 3', color: '#52c41a' }, + { id: 4, cocraftX: -719, cocraftY: 301, text: 'Comment 4', color: '#faad14' }, + { id: 5, cocraftX: -1085, cocraftY: 257, text: 'Comment 5', color: '#0000FF' }, + { id: 6, cocraftX: -1585, cocraftY: 257, text: 'Comment 6', color: '#0000FF' }, + ]; + }, + + toggleComments() { + const show = !this.data.showComments; + if (show) { + this.initCommentOverlays(); + } else { + this.setData({ showComments: false, commentPins: [], commentTransform: null }); + } + }, + + // Compute fixed base coordinates and set initial transform (called once per file load). + initCommentOverlays() { + if (!this.View) { + return; + } + const t = this.View.getContentTransform(); + + const comments = this.getComments(); + const pins = comments.map(function(c) { + return { + id: c.id, + text: c.text, + color: c.color, + baseX: (c.cocraftX - t.boundsOriginX) * t.fitScale + t.centerOffsetX, + baseY: (c.cocraftY - t.boundsOriginY) * t.fitScale + t.centerOffsetY, + }; + }); + + var zoom = 1; + var offsetX = 0; + var offsetY = 0; + if (this.gestureManager) { + zoom = this.gestureManager.zoom; + offsetX = this.gestureManager.offsetX; + offsetY = this.gestureManager.offsetY; + } + + this.setData({ + showComments: true, + commentPins: pins, + commentTransform: { + zoom: zoom, + panOffsetX: offsetX, + panOffsetY: offsetY, + dpr: this.dpr + } + }); + }, +}); diff --git a/pagx/wechat/wx_demo/pages/viewer/viewer.json b/pagx/wechat/wx_demo/pages/viewer/viewer.json new file mode 100644 index 0000000000..0a4de4731a --- /dev/null +++ b/pagx/wechat/wx_demo/pages/viewer/viewer.json @@ -0,0 +1,5 @@ +{ + "navigationBarTitleText": "PAGX Viewer", + "disableScroll": true, + "usingComponents": {} +} diff --git a/pagx/wechat/wx_demo/pages/viewer/viewer.wxml b/pagx/wechat/wx_demo/pages/viewer/viewer.wxml new file mode 100644 index 0000000000..0ed8accd4b --- /dev/null +++ b/pagx/wechat/wx_demo/pages/viewer/viewer.wxml @@ -0,0 +1,95 @@ + + + + + + + {{samples[currentIndex].name}} + + + + + + + + + + + + + + + + + + {{item.text}} + + + + + + + Loading... + + + + + Switching... + + + + + Performance Monitor + + Quality: + {{perfStats.quality}} + + + Smoothness: + {{perfStats.smoothness}}/100 + + + FPS: + {{perfStats.fps}} (avg: {{perfStats.avgFPS}}) + + + Frame Time: + {{perfStats.frameTime}}ms + + + Frame Drops: + {{perfStats.dropRate}}% + + + + diff --git a/pagx/wechat/wx_demo/pages/viewer/viewer.wxss b/pagx/wechat/wx_demo/pages/viewer/viewer.wxss new file mode 100644 index 0000000000..8ab1f6f5b4 --- /dev/null +++ b/pagx/wechat/wx_demo/pages/viewer/viewer.wxss @@ -0,0 +1,245 @@ +.container { + width: 100%; + height: 100vh; + background-color: #2a2a2a; + display: flex; + flex-direction: column; +} + +.file-selector { + background: rgba(0, 0, 0, 0.9); + padding: 25rpx 30rpx; + border-bottom: 1rpx solid rgba(255, 255, 255, 0.1); + display: flex; + align-items: center; + gap: 15rpx; + flex-shrink: 0; + z-index: 100; +} + +.picker { + flex: 0 0 auto; + width: 370rpx; + height: 68rpx; + display: flex; +} + +.picker-display { + display: flex; + align-items: center; + justify-content: space-between; + background: rgba(255, 255, 255, 0.1); + padding: 0 30rpx; + border-radius: 8rpx; + border: 1rpx solid rgba(255, 255, 255, 0.2); + width: 100%; + height: 68rpx; + box-sizing: border-box; + transition: all 0.3s; +} + +.picker-display:active { + background: rgba(255, 255, 255, 0.15); +} + +.picker-text { + color: #fff; + font-size: 28rpx; + font-weight: 500; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.picker-arrow { + color: #07c160; + font-size: 24rpx; + margin-left: 20rpx; + flex-shrink: 0; +} + +.reset-btn { + padding: 0 35rpx; + font-size: 28rpx; + background: #07c160; + color: #fff; + border: none; + border-radius: 8rpx; + height: 68rpx; + box-sizing: border-box; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.reset-btn::after { + border: none; +} + +.perf-btn { + padding: 0 35rpx; + font-size: 28rpx; + background: rgba(255, 255, 255, 0.1); + color: #999; + border: 1rpx solid rgba(255, 255, 255, 0.2); + border-radius: 8rpx; + height: 68rpx; + box-sizing: border-box; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.3s; +} + +.perf-btn.active { + background: #1989fa; + color: #fff; + border-color: #1989fa; +} + +.perf-btn::after { + border: none; +} + +.canvas-container { + flex: 1; + position: relative; + overflow: hidden; +} + +.canvas { + width: 100%; + height: 100%; +} + +.loading { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: rgba(0, 0, 0, 0.8); + padding: 40rpx; + border-radius: 8rpx; + color: #fff; + font-size: 28rpx; +} + +.loading-file { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: rgba(0, 0, 0, 0.7); + padding: 30rpx 50rpx; + border-radius: 8rpx; + color: #fff; + font-size: 24rpx; +} + +.info { + position: absolute; + top: 20rpx; + right: 20rpx; + background: rgba(0, 0, 0, 0.7); + color: #fff; + padding: 10rpx 20rpx; + border-radius: 4rpx; + font-size: 24rpx; +} + +.perf-panel { + position: absolute; + top: 20rpx; + left: 20rpx; + background: rgba(0, 0, 0, 0.85); + padding: 25rpx 30rpx; + border-radius: 12rpx; + border: 1rpx solid rgba(255, 255, 255, 0.2); + min-width: 350rpx; + backdrop-filter: blur(10rpx); +} + +.perf-title { + color: #fff; + font-size: 28rpx; + font-weight: bold; + margin-bottom: 15rpx; + border-bottom: 1rpx solid rgba(255, 255, 255, 0.2); + padding-bottom: 10rpx; +} + +.perf-row { + display: flex; + justify-content: space-between; + align-items: center; + margin: 12rpx 0; +} + +.perf-label { + color: #999; + font-size: 24rpx; +} + +.perf-value { + color: #fff; + font-size: 24rpx; + font-weight: 500; + font-family: 'Courier New', monospace; +} + +.quality-Excellent { + color: #52c41a; +} + +.quality-Good { + color: #73d13d; +} + +.quality-Fair { + color: #faad14; +} + +.quality-Poor { + color: #ff7a45; +} + +.quality-Very { + color: #f5222d; +} + +/* Comment Overlays */ +.comment-pin { + position: absolute; + z-index: 50; + pointer-events: none; +} + +.comment-dot { + width: 20rpx; + height: 20rpx; + border-radius: 50%; + border: 3rpx solid #fff; + box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.3); +} + +.comment-bubble { + position: absolute; + left: 24rpx; + top: -8rpx; + background: rgba(0, 0, 0, 0.8); + padding: 8rpx 16rpx; + border-radius: 8rpx; + white-space: nowrap; + max-width: 300rpx; +} + +.comment-text { + color: #fff; + font-size: 20rpx; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/pagx/wechat/wx_demo/project.config.json b/pagx/wechat/wx_demo/project.config.json new file mode 100644 index 0000000000..9dc2955b7c --- /dev/null +++ b/pagx/wechat/wx_demo/project.config.json @@ -0,0 +1,56 @@ +{ + "description": "PAGX Viewer MVP", + "setting": { + "bundle": false, + "userConfirmedBundleSwitch": false, + "urlCheck": true, + "scopeDataCheck": false, + "coverView": true, + "es6": true, + "postcss": true, + "compileHotReLoad": false, + "lazyloadPlaceholderEnable": false, + "preloadBackgroundData": false, + "minified": false, + "autoAudits": false, + "newFeature": false, + "uglifyFileName": false, + "uploadWithSourceMap": true, + "useIsolateContext": true, + "nodeModules": false, + "enhance": true, + "useMultiFrameRuntime": true, + "useApiHook": true, + "useApiHostProcess": true, + "showShadowRootInWxmlPanel": true, + "packNpmManually": false, + "enableEngineNative": false, + "packNpmRelationList": [], + "minifyWXSS": true, + "showES6CompileOption": false, + "minifyWXML": true, + "compileWorklet": false, + "localPlugins": false, + "disableUseStrict": false, + "useCompilerPlugins": false, + "condition": false, + "swc": false, + "disableSWC": true, + "babelSetting": { + "ignore": [], + "disablePlugins": [], + "outputPath": "" + } + }, + "compileType": "miniprogram", + "libVersion": "3.14.0", + "appid": "wx0b5489422d4aeaf4", + "projectname": "pagx-viewer-mvp", + "condition": {}, + "simulatorPluginLibVersion": {}, + "packOptions": { + "ignore": [], + "include": [] + }, + "editorSetting": {} +} \ No newline at end of file diff --git a/pagx/wechat/wx_demo/project.private.config.json b/pagx/wechat/wx_demo/project.private.config.json new file mode 100644 index 0000000000..b6ed544261 --- /dev/null +++ b/pagx/wechat/wx_demo/project.private.config.json @@ -0,0 +1,23 @@ +{ + "libVersion": "3.14.0", + "projectname": "wx_demo", + "condition": {}, + "setting": { + "urlCheck": true, + "coverView": true, + "lazyloadPlaceholderEnable": false, + "skylineRenderEnable": false, + "preloadBackgroundData": false, + "autoAudits": false, + "useApiHook": true, + "showShadowRootInWxmlPanel": true, + "useStaticServer": false, + "useLanDebug": false, + "showES6CompileOption": false, + "compileHotReLoad": true, + "checkInvalidKey": true, + "ignoreDevUnusedFiles": true, + "bigPackageSizeSupport": false, + "useIsolateContext": true + } +} \ No newline at end of file diff --git a/pagx/wechat/wx_demo/sitemap.json b/pagx/wechat/wx_demo/sitemap.json new file mode 100644 index 0000000000..55d1d29ed4 --- /dev/null +++ b/pagx/wechat/wx_demo/sitemap.json @@ -0,0 +1,7 @@ +{ + "desc": "关于本文件的更多信息,请参考文档 https://developers.weixin.qq.com/miniprogram/dev/framework/sitemap.html", + "rules": [{ + "action": "allow", + "page": "*" + }] +} diff --git a/pagx/wechat/wx_demo/utils/performance-monitor.js b/pagx/wechat/wx_demo/utils/performance-monitor.js new file mode 100644 index 0000000000..6523c4f959 --- /dev/null +++ b/pagx/wechat/wx_demo/utils/performance-monitor.js @@ -0,0 +1,290 @@ +/** + * Copyright (C) 2026 Tencent. All Rights Reserved. + * Performance monitoring utility for measuring rendering smoothness + */ + +class PerformanceMonitor { + constructor() { + this.enabled = false; + this.startTime = 0; + this.lastFrameTime = 0; + this.frameTimes = []; + this.gestureStartTime = 0; + this.gestureFrameTimes = []; + + // Statistics + this.currentFPS = 0; + this.averageFPS = 0; + this.minFPS = Infinity; + this.maxFPS = 0; + this.averageFrameTime = 0; + this.maxFrameTime = 0; + this.droppedFrames = 0; + this.totalFrames = 0; + this.validFrames = 0; // Only count frames used in calculations + + // Gesture-specific metrics + this.gestureResponseTime = 0; + this.gestureAverageFPS = 0; + this.gestureMinFPS = Infinity; + + // Config + this.targetFrameTime = 1000 / 60; // 60 FPS = ~16.67ms per frame + this.droppedFrameThreshold = this.targetFrameTime * 1.5; // 25ms + this.sampleWindow = 60; // Keep last 60 frame times for rolling average + this.minValidFrameTime = 5; // Minimum frame time to consider valid (ms) + + // Callbacks + this.onStatsUpdate = null; + } + + start() { + this.enabled = true; + this.reset(); + } + + stop() { + this.enabled = false; + } + + reset() { + this.startTime = Date.now(); + this.lastFrameTime = this.startTime; + this.frameTimes = []; + this.gestureFrameTimes = []; + this.minFPS = Infinity; + this.maxFPS = 0; + this.droppedFrames = 0; + this.totalFrames = 0; + this.validFrames = 0; + this.currentFPS = 0; + this.averageFPS = 0; + this.gestureMinFPS = Infinity; + this.gestureAverageFPS = 0; + this.gestureResponseTime = 0; + } + + recordFrame() { + if (!this.enabled) return; + + const now = Date.now(); + const frameTime = now - this.lastFrameTime; + + this.totalFrames++; + + // Always update lastFrameTime to avoid accumulating errors + this.lastFrameTime = now; + + // Skip first frame (initialization timing is unreliable) + if (this.totalFrames === 1) { + return; + } + + // Skip abnormally small intervals (< 5ms) to avoid timer precision issues + // But don't block forever - if we get 10+ frames, start accepting them + if (frameTime < this.minValidFrameTime && this.totalFrames < 10) { + return; + } + + this.validFrames++; + + // Update frame timing array + this.frameTimes.push(frameTime); + if (this.frameTimes.length > this.sampleWindow) { + this.frameTimes.shift(); + } + + // Track dropped frames (frame time > 25ms means dropped frames) + if (frameTime > this.droppedFrameThreshold) { + this.droppedFrames++; + } + + // Calculate current FPS (instantaneous) with reasonable cap + // Cap at 120 FPS (most displays are 60-120Hz) + this.currentFPS = Math.min(120, Math.round(1000 / frameTime)); + + // Track min/max FPS + if (this.currentFPS < this.minFPS) { + this.minFPS = this.currentFPS; + } + if (this.currentFPS > this.maxFPS) { + this.maxFPS = this.currentFPS; + } + + // Calculate average FPS based on valid frames only + const elapsed = now - this.startTime; + if (elapsed > 0 && this.validFrames > 0) { + this.averageFPS = Math.min(120, Math.round((this.validFrames * 1000) / elapsed)); + } + + // Calculate average and max frame time + if (this.frameTimes.length > 0) { + const sum = this.frameTimes.reduce((a, b) => a + b, 0); + this.averageFrameTime = Math.round(sum / this.frameTimes.length * 10) / 10; + this.maxFrameTime = Math.round(Math.max(...this.frameTimes) * 10) / 10; + } + + // Warn when FPS drops below 30 + if (this.currentFPS < 30 && this.validFrames > 10) { + console.warn(`[Performance] Low FPS detected: ${this.currentFPS} (frameTime: ${frameTime}ms)`); + } + + // Gesture-specific tracking + if (this.gestureStartTime > 0) { + this.gestureFrameTimes.push(frameTime); + + // Calculate gesture instantaneous FPS with cap + const gestureFPS = Math.min(120, Math.round(1000 / frameTime)); + if (gestureFPS < this.gestureMinFPS) { + this.gestureMinFPS = gestureFPS; + } + + // Calculate gesture average FPS + const gestureElapsed = now - this.gestureStartTime; + if (gestureElapsed > 0 && this.gestureFrameTimes.length > 0) { + this.gestureAverageFPS = Math.min(120, Math.round((this.gestureFrameTimes.length * 1000) / gestureElapsed)); + } + } + + // Trigger callback every 10 valid frames + if (this.onStatsUpdate && this.validFrames % 10 === 0) { + this.onStatsUpdate(this.getStats()); + } + } + + onGestureStart() { + if (!this.enabled) return; + + this.gestureStartTime = Date.now(); + this.gestureFrameTimes = []; + this.gestureMinFPS = Infinity; + this.gestureResponseTime = 0; + } + + onGestureFirstFrame() { + if (!this.enabled || this.gestureStartTime === 0) return; + + // Measure time from gesture start to first rendered frame + this.gestureResponseTime = Date.now() - this.gestureStartTime; + } + + onGestureEnd() { + if (!this.enabled) return; + + this.gestureStartTime = 0; + + // Trigger final gesture stats + if (this.onStatsUpdate) { + this.onStatsUpdate(this.getStats()); + } + } + + getStats() { + const droppedFrameRate = this.validFrames > 0 + ? Math.round((this.droppedFrames / this.validFrames) * 100) + : 0; + + return { + // Overall metrics + currentFPS: this.currentFPS, + averageFPS: this.averageFPS, + minFPS: this.minFPS === Infinity ? 0 : this.minFPS, + maxFPS: this.maxFPS, + + // Frame timing + averageFrameTime: this.averageFrameTime, + maxFrameTime: this.maxFrameTime, + targetFrameTime: this.targetFrameTime, + + // Frame drops + droppedFrames: this.droppedFrames, + totalFrames: this.totalFrames, + validFrames: this.validFrames, + droppedFrameRate: droppedFrameRate, + + // Gesture-specific + gestureResponseTime: this.gestureResponseTime, + gestureAverageFPS: this.gestureAverageFPS, + gestureMinFPS: this.gestureMinFPS === Infinity ? 0 : this.gestureMinFPS, + isGestureActive: this.gestureStartTime > 0, + + // Smoothness rating (0-100) + smoothness: this.calculateSmoothness() + }; + } + + calculateSmoothness() { + // Calculate smoothness score based on multiple factors + // 100 = perfectly smooth, 0 = very stuttery + + if (this.validFrames < 10) { + return 100; // Not enough data + } + + // Factor 1: Average FPS (40% weight) + // Perfect score at 60+ FPS, linear scale below + const fpsScore = Math.min(100, (this.averageFPS / 60) * 100); + + // Factor 2: Frame drops (30% weight) + // Perfect score at 0%, linearly decrease (2% drop = -4 points) + const dropRate = this.droppedFrames / this.validFrames; + const dropScore = Math.max(0, 100 - dropRate * 100 * 2); + + // Factor 3: Frame time variance (30% weight) + // Low variance = smooth, high variance = jittery + let varianceScore = 100; + if (this.frameTimes.length > 1) { + const stdDev = this.calculateStdDev(this.frameTimes); + // Standard deviation > 5ms indicates noticeable jitter + // Linearly penalize: 5ms stdDev = 50 points, 10ms = 0 points + varianceScore = Math.max(0, 100 - (stdDev / 10) * 100); + } + + // Weighted average + const score = fpsScore * 0.4 + dropScore * 0.3 + varianceScore * 0.3; + + return Math.round(score); + } + + calculateStdDev(values) { + if (values.length === 0) return 0; + + const mean = values.reduce((a, b) => a + b, 0) / values.length; + const squaredDiffs = values.map(value => Math.pow(value - mean, 2)); + const variance = squaredDiffs.reduce((a, b) => a + b, 0) / values.length; + return Math.sqrt(variance); + } + + getSummary() { + const stats = this.getStats(); + + let quality = 'Excellent'; + if (stats.smoothness < 90) quality = 'Good'; + if (stats.smoothness < 75) quality = 'Fair'; + if (stats.smoothness < 60) quality = 'Poor'; + if (stats.smoothness < 40) quality = 'Very Poor'; + + return { + quality: quality, + smoothness: stats.smoothness, + averageFPS: stats.averageFPS, + droppedFrameRate: stats.droppedFrameRate, + gestureAverageFPS: stats.gestureAverageFPS, + gestureResponseTime: stats.gestureResponseTime + }; + } + + logStats() { + const stats = this.getStats(); + console.log('=== Performance Statistics ==='); + console.log(`FPS: ${stats.currentFPS} (avg: ${stats.averageFPS}, min: ${stats.minFPS}, max: ${stats.maxFPS})`); + console.log(`Frame Time: ${stats.averageFrameTime}ms (max: ${stats.maxFrameTime}ms, target: ${stats.targetFrameTime}ms)`); + console.log(`Frames: ${stats.validFrames} valid / ${stats.totalFrames} total`); + console.log(`Dropped Frames: ${stats.droppedFrames}/${stats.validFrames} (${stats.droppedFrameRate}%)`); + console.log(`Gesture Response: ${stats.gestureResponseTime}ms`); + console.log(`Gesture FPS: ${stats.gestureAverageFPS} (min: ${stats.gestureMinFPS})`); + console.log(`Smoothness Score: ${stats.smoothness}/100 (${this.getSummary().quality})`); + } +} + +export { PerformanceMonitor }; diff --git a/src/pagx/PAGXDocument.cpp b/src/pagx/PAGXDocument.cpp index 7147e1ebe7..66e806fadb 100644 --- a/src/pagx/PAGXDocument.cpp +++ b/src/pagx/PAGXDocument.cpp @@ -53,22 +53,30 @@ static void PruneExpiredScenes(std::vector>* scenes) { scenes->erase(std::remove_if(scenes->begin(), scenes->end(), IsExpiredScene), scenes->end()); } -static void AppendExternalFilePaths(const PAGXDocument* document, std::vector* paths) { +static void AppendExternalFilePaths(const PAGXDocument* document, ImageResourceProvider* provider, + std::vector* paths) { if (document == nullptr || paths == nullptr) { return; } for (auto& node : document->nodes) { if (node->nodeType() == NodeType::Image) { auto* image = static_cast(node.get()); - if (image->data == nullptr && IsExternalFilePath(image->filePath)) { - paths->push_back(image->filePath); + if (image->data != nullptr) { + continue; + } + if (!IsExternalFilePath(image->filePath)) { + continue; + } + if (provider && provider->hasImage(image->filePath)) { + continue; } + paths->push_back(image->filePath); } else if (node->nodeType() == NodeType::Layer) { auto* layer = static_cast(node.get()); if (layer->composition == nullptr && IsExternalFilePath(layer->compositionFilePath)) { paths->push_back(layer->compositionFilePath); } else if (layer->externalDoc != nullptr) { - AppendExternalFilePaths(layer->externalDoc.get(), paths); + AppendExternalFilePaths(layer->externalDoc.get(), provider, paths); } } } @@ -289,7 +297,7 @@ bool PAGXDocument::hasUnresolvedImports() const { std::vector PAGXDocument::getExternalFilePaths() const { std::vector paths = {}; - AppendExternalFilePaths(this, &paths); + AppendExternalFilePaths(this, _imageResourceProvider.get(), &paths); return paths; } @@ -470,6 +478,10 @@ void PAGXDocument::notifyChange(const std::vector& dirtyNodes, bool layou if (ownedDirty.empty()) { return; } + // Invalidate the filePath -> Layer index so the next query rebuilds it from the (possibly + // modified) tree topology. Edits may add/remove Image references or change filePath values. + layersByImageFilePathBuilt = false; + layersByImageFilePath.clear(); // Resolve content nodes (Image, SolidColor, Gradient, Fill, Stroke, Group, Filter, Style, etc.) // to their owning Layers. refreshNodes only processes Layer nodes directly; non-Layer content // nodes are resolved here so callers can pass them to notifyChange without having to find the @@ -566,4 +578,99 @@ void PAGXDocument::unregisterLiveScene(PAGScene* scene) { } } +void PAGXDocument::setImageResourceProvider(std::shared_ptr provider) { + _imageResourceProvider = std::move(provider); +} + +// Records the Image node filePath referenced by `color` (when it is an ImagePattern) into +// `index`, scoped to the owning `layer`. +static void RecordImagePattern( + const ColorSource* color, const Layer* layer, + std::unordered_map>* index) { + if (!color || color->nodeType() != NodeType::ImagePattern) { + return; + } + const auto* pattern = static_cast(color); + if (!pattern->image || pattern->image->filePath.empty()) { + return; + } + (*index)[pattern->image->filePath].insert(layer); +} + +// Records into `index` every Image node filePath referenced by an ImagePattern inside +// `element`, scoped to the owning `layer`. The inner unordered_set deduplicates layers that +// have multiple fills or strokes pointing at the same filePath so the resulting index keeps +// each Layer exactly once per path. +static void CollectImageFilePathsFromElement( + const Element* element, const Layer* layer, + std::unordered_map>* index) { + if (!element || !layer) { + return; + } + switch (element->nodeType()) { + case NodeType::Fill: + RecordImagePattern(static_cast(element)->color, layer, index); + break; + case NodeType::Stroke: + RecordImagePattern(static_cast(element)->color, layer, index); + break; + case NodeType::Group: + case NodeType::TextBox: { + const auto* group = static_cast(element); + for (const auto* child : group->elements) { + CollectImageFilePathsFromElement(child, layer, index); + } + break; + } + default: + break; + } +} + +static void CollectImageFilePathsFromLayers( + const std::vector& layers, + std::unordered_map>* index, + std::unordered_set* visitedCompositions) { + for (const auto* layer : layers) { + if (!layer) { + continue; + } + for (const auto* element : layer->contents) { + CollectImageFilePathsFromElement(element, layer, index); + } + CollectImageFilePathsFromLayers(layer->children, index, visitedCompositions); + // A Composition may be referenced by many Layers; skip re-entry once we have already + // walked its inner layers to avoid quadratic blowups in documents that instance the + // same Composition repeatedly. + if (layer->composition && visitedCompositions->insert(layer->composition).second) { + CollectImageFilePathsFromLayers(layer->composition->layers, index, visitedCompositions); + } + } +} + +const std::vector& PAGXDocument::findLayersByImageFilePath( + const std::string& imageFilePath) { + static const std::vector empty; + if (imageFilePath.empty()) { + return empty; + } + if (!layersByImageFilePathBuilt) { + std::unordered_map> tempIndex; + std::unordered_set visitedCompositions; + CollectImageFilePathsFromLayers(layers, &tempIndex, &visitedCompositions); + layersByImageFilePath.clear(); + layersByImageFilePath.reserve(tempIndex.size()); + for (auto& [path, layerSet] : tempIndex) { + std::vector layerList(layerSet.begin(), layerSet.end()); + layersByImageFilePath.emplace(path, std::move(layerList)); + } + layersByImageFilePathBuilt = true; + } + auto it = layersByImageFilePath.find(imageFilePath); + if (it == layersByImageFilePath.end()) { + return empty; + } + return it->second; +} + } // namespace pagx diff --git a/src/pagx/PAGXImporter.cpp b/src/pagx/PAGXImporter.cpp index 0f35856ad5..88d197e114 100644 --- a/src/pagx/PAGXImporter.cpp +++ b/src/pagx/PAGXImporter.cpp @@ -343,6 +343,7 @@ static void ParseResources(const DOMNode* node, PAGXDocument* doc) { } child = child->nextSibling; } + // Second pass: fully parse each resource. Pre-registered nodes are reused by makeNodeFromXML. child = node->firstChild; while (child) { diff --git a/src/pagx/TextLayout.cpp b/src/pagx/TextLayout.cpp index 7405c661ec..d877c904dc 100644 --- a/src/pagx/TextLayout.cpp +++ b/src/pagx/TextLayout.cpp @@ -1675,15 +1675,16 @@ TextLayoutResult TextLayout::Layout(const std::vector& textElements, return {}; } TextLayoutContext layoutContext(context); + TextLayoutResult result = {}; if (AllHaveEmbeddedGlyphRuns(textElements)) { // Embedded path: only compute bounds. TextBlob generation is deferred to the caller // (TextBox::updateLayout or LayerBuilder) which applies the inverse matrix. - TextLayoutResult result = {}; result.bounds = MergeEmbeddedBounds(textElements); - return result; + } else { + auto mutableElements = textElements; + result = layoutContext.processTextWithLayout(mutableElements, params); } - auto mutableElements = textElements; - return layoutContext.processTextWithLayout(mutableElements, params); + return result; } } // namespace pagx diff --git a/src/pagx/runtime/PAGComposition.cpp b/src/pagx/runtime/PAGComposition.cpp index 17fc8af8d4..07aa1b9c41 100644 --- a/src/pagx/runtime/PAGComposition.cpp +++ b/src/pagx/runtime/PAGComposition.cpp @@ -85,12 +85,12 @@ std::shared_ptr PAGComposition::MakeChild( // When the composition is not yet loaded (composition == nullptr), BuildCompositionSubtree // returns an empty root. Build a minimal tgfx slot layer so the PAGComposition has a valid // runtimeLayer for syncChildren to attach children into later. + auto* externalDoc = ownerLayer->externalDoc.get(); + composition->document = externalDoc != nullptr ? externalDoc : parentScene->document.get(); if (composition->runtimeLayer == nullptr) { composition->runtimeLayer = - LayerBuilder::BuildLayerInto(ownerLayer, composition->binding.get()); + LayerBuilder::BuildLayerInto(ownerLayer, composition->binding.get(), composition->document); } - auto* externalDoc = ownerLayer->externalDoc.get(); - composition->document = externalDoc != nullptr ? externalDoc : parentScene->document.get(); if (externalDoc != nullptr) { externalDoc->registerLiveScene(parentScene); } @@ -198,7 +198,7 @@ void PAGComposition::refreshNodes(const std::vector& dirtyNodes, } auto* dirtyLayer = static_cast(dirty); if (binding->get(dirtyLayer) != nullptr) { - LayerBuilder::RefreshLayerInPlace(dirtyLayer, binding.get()); + LayerBuilder::RefreshLayerInPlace(dirtyLayer, binding.get(), document); } } } @@ -234,7 +234,7 @@ std::shared_ptr PAGComposition::BuildChildLayer( } auto slot = binding->get(layer); if (slot == nullptr) { - slot = LayerBuilder::BuildLayerInto(layer, binding); + slot = LayerBuilder::BuildLayerInto(layer, binding, scene ? scene->document.get() : nullptr); } if (slot != nullptr && childComposition->runtimeLayer != nullptr) { slot->addChild(childComposition->runtimeLayer); @@ -243,7 +243,8 @@ std::shared_ptr PAGComposition::BuildChildLayer( } auto layerRuntime = binding->get(layer); if (layerRuntime == nullptr) { - layerRuntime = LayerBuilder::BuildLayerInto(layer, binding); + layerRuntime = + LayerBuilder::BuildLayerInto(layer, binding, scene ? scene->document.get() : nullptr); } if (layerRuntime == nullptr) { return nullptr; diff --git a/src/pagx/xml/XMLParser.cpp b/src/pagx/xml/XMLParser.cpp index d860e337e5..a91f2e70f5 100644 --- a/src/pagx/xml/XMLParser.cpp +++ b/src/pagx/xml/XMLParser.cpp @@ -212,6 +212,7 @@ bool XMLParser::parse(const uint8_t* data, size_t length) { static_cast(length), true); _expatParser = nullptr; + return XML_STATUS_ERROR != status; } diff --git a/src/renderer/LayerBuilder.cpp b/src/renderer/LayerBuilder.cpp index f6f348e5ce..377da0dc9f 100644 --- a/src/renderer/LayerBuilder.cpp +++ b/src/renderer/LayerBuilder.cpp @@ -85,6 +85,7 @@ #include "tgfx/layers/Layer.h" #include "tgfx/layers/LayerMaskType.h" #include "tgfx/layers/LayerPaint.h" +#include "tgfx/layers/LayerType.h" #include "tgfx/layers/StrokeAlign.h" #include "tgfx/layers/VectorLayer.h" #include "tgfx/layers/filters/BlendFilter.h" @@ -119,6 +120,23 @@ namespace pagx { +// Per-category switches to skip PAGX layer effects during conversion to the tgfx layer tree. +// Each effect normally forces tgfx to allocate an offscreen surface at render time and run a +// sampling pass over the affected layer; large designs can carry hundreds of such effects +// whose combined offscreen cost dominates the frame budget. Flip individual switches to 1 in +// debug builds to measure how much of the rendering load is attributable to each effect type. +// The visual result with an effect disabled is wrong (missing shadows / blur), but everything +// downstream still parses, lays out, and renders without crashing. +// +// PAG_DISABLE_BACKGROUND_BLUR_STYLE: skips BackgroundBlurStyle (offscreen background snapshot +// plus blur pass; usually the heaviest single category in dense designs). +// PAG_DISABLE_SHADOW_STYLES: skips DropShadowStyle and InnerShadowStyle. +// PAG_DISABLE_LAYER_FILTERS: skips all LayerFilters (BlurFilter, DropShadow/InnerShadow +// filters, BlendFilter, ColorMatrixFilter). BlurFilter with very large sigma dominates here. +#define PAG_DISABLE_BACKGROUND_BLUR_STYLE 0 +#define PAG_DISABLE_SHADOW_STYLES 0 +#define PAG_DISABLE_LAYER_FILTERS 0 + // Runtime target for a Layer's tgfx::Layer that keeps the layer transform decomposed into its // animatable sources (x, y translation and the 2D matrix). The final tgfx matrix is recomposed as // Translate(x, y) * matrix, mirroring applyLayerAttributes so x/y and matrix animations stack @@ -199,7 +217,10 @@ static std::shared_ptr ImageFromDataURI(const std::string& dataURI) return tgfx::Image::MakeFromEncoded(ToTGFXData(data)); } -// Build context that maintains state during layer tree construction +// Build context that maintains state during layer tree construction. Designed so the same +// instance can be reused: the session-based flow keeps the context alive after build() so that +// later rebuildForFilePath() calls can find the already-created tgfx layers and regenerate +// their contents in place. class LayerBuilderContext { public: LayerBuilderContext() = default; @@ -208,6 +229,10 @@ class LayerBuilderContext { _needsRuntimeData = value; } + void setDocument(const PAGXDocument* document) { + _document = document; + } + // Builds a single Composition's subtree, exposed for PAGComposition runtime slots that need // their own independent layerMap. The returned LayerBuildResult.root is a fresh container layer // populated with the composition's child layers. Per-slot mask resolution still runs on the @@ -223,14 +248,33 @@ class LayerBuilderContext { } resolvePendingMasks(); } - _result.root = root; - return std::move(_result); + LayerBuildResult result = std::move(_result); + _result = {}; + result.root = root; + return result; } LayerBuildResult buildWithMap(const PAGXDocument& document) { auto root = build(document); - _result.root = root; - return std::move(_result); + LayerBuildResult result = {}; + result.root = root; + // Transfer the RuntimeBinding populated during build()/convertLayer so callers (PAGScene, + // BuildForRuntime) can drive animation channel writers. The layerMap below is rebuilt + // separately and must not be clobbered, so we move only the binding rather than the whole + // _result. + result.binding = std::move(_result.binding); + _result = {}; + // LayerBuildResult exposes a 1:1 pagx Layer -> tgfx Layer map for historical reasons. When + // a Composition is referenced by multiple Layers a single pagx Layer node actually produces + // several tgfx Layers; we only surface the first one here to keep the public contract + // stable. Session users that need every copy go through getTgfxLayers() instead. + result.layerMap.reserve(_tgfxLayersByPagxLayer.size()); + for (const auto& [pagxLayer, tgfxLayers] : _tgfxLayersByPagxLayer) { + if (!tgfxLayers.empty()) { + result.layerMap.emplace(pagxLayer, tgfxLayers.front()); + } + } + return result; } // Rewrites the current state of a single Layer node onto its existing tgfx::Layer, preserving the @@ -570,6 +614,7 @@ class LayerBuilderContext { } std::shared_ptr build(const PAGXDocument& document) { + _document = &document; // Build layer tree. auto rootLayer = tgfx::Layer::Make(); // Apply canvas clipping: the root layer clips to the canvas dimensions. @@ -586,8 +631,9 @@ class LayerBuilderContext { void resolvePendingMasks() { for (const auto& [layer, maskPagx, maskType] : _pendingMasks) { - auto maskLayer = _result.getLayer(maskPagx); - if (maskLayer != nullptr) { + auto it = _tgfxLayersByPagxLayer.find(maskPagx); + if (it != _tgfxLayersByPagxLayer.end() && !it->second.empty()) { + auto maskLayer = it->second.front(); // tgfx requires mask layer to be visible for hasValidMask() check. // The mask layer won't be drawn because maskOwner is set. maskLayer->setVisible(true); @@ -598,6 +644,68 @@ class LayerBuilderContext { _pendingMasks.clear(); } + // Returns every tgfx layer produced for the given pagx Layer. Order matches convertLayer() + // invocation order; an empty vector means the pagx Layer was never visited. + std::vector> getTgfxLayers(const Layer* pagxLayer) const { + auto it = _tgfxLayersByPagxLayer.find(pagxLayer); + if (it == _tgfxLayersByPagxLayer.end()) { + return {}; + } + return it->second; + } + + // Evicts any cached tgfx::Image whose backing Image node has the given filePath so the next + // conversion goes through getOrCreateImage() again and re-queries the provider. + void invalidateImagesByFilePath(const PAGXDocument& document, const std::string& filePath) { + if (filePath.empty()) { + return; + } + for (auto& node : document.nodes) { + if (!node || node->nodeType() != NodeType::Image) { + continue; + } + auto* imageNode = static_cast(node.get()); + if (imageNode->filePath != filePath) { + continue; + } + _imageCache.erase(imageNode); + } + } + + // Drops the entire image cache. Must be called when the document's node tree undergoes + // structural changes (e.g. notifyChange with node additions/removals) because the raw Image* + // keys may become dangling after such edits. + void invalidateAllImages() { + _imageCache.clear(); + } + + // Builds the tgfx VectorElement list for a pagx Layer's contents, skipping elements that + // convert to null. Shared by convertVectorLayer() (initial build) and rebuildVectorContents() + // (progressive image refresh) so the two paths cannot drift. + std::vector> buildVectorContents(const Layer* node) { + std::vector> contents; + contents.reserve(node->contents.size()); + for (const auto& element : node->contents) { + auto tgfxElement = convertVectorElement(element); + if (tgfxElement) { + contents.push_back(tgfxElement); + } + } + return contents; + } + + // Regenerates the contents of a VectorLayer by walking the original pagx Layer's contents + // list through convertVectorElement(). Non-VectorLayer targets are skipped because they do + // not own a setContents()-style slot (Composition container layers etc). + bool rebuildVectorContents(const Layer* pagxLayer, tgfx::Layer* tgfxLayer) { + if (!pagxLayer || !tgfxLayer || tgfxLayer->type() != tgfx::LayerType::Vector) { + return false; + } + auto* vectorLayer = static_cast(tgfxLayer); + vectorLayer->setContents(buildVectorContents(pagxLayer)); + return true; + } + private: std::shared_ptr convertLayer(const Layer* node) { if (!node) { @@ -621,7 +729,10 @@ class LayerBuilderContext { auto target = std::unique_ptr(new LayerRuntimeTarget()); auto* layerTarget = static_cast(_result.binding.setTarget(node, std::move(target))); - // Register layer for mask lookups and animation writers. + // Register layer for mask lookups and later rebuildForFilePath(). A single pagx Layer + // may appear here multiple times when its owning Composition is instanced more than + // once, so we append rather than overwrite. + _tgfxLayersByPagxLayer[node].push_back(layer); _result.binding.set(node, layer); bindLayerChannels(node); @@ -708,15 +819,7 @@ class LayerBuilderContext { std::shared_ptr convertVectorLayer(const Layer* node) { auto layer = tgfx::VectorLayer::Make(); - std::vector> contents; - contents.reserve(node->contents.size()); - for (const auto& element : node->contents) { - auto tgfxElement = convertVectorElement(element); - if (tgfxElement) { - contents.push_back(tgfxElement); - } - } - layer->setContents(contents); + layer->setContents(buildVectorContents(node)); return layer; } @@ -732,6 +835,108 @@ class LayerBuilderContext { } } + // FNV-1a 64-bit mixing step over a raw byte range. Kept as an explicit helper (no lambda) + // so ComputeTextBlobFingerprint stays readable while honoring the no-lambda project rule. + static void FnvMix(uint64_t* h, const void* data, size_t bytes) { + constexpr uint64_t FNV_PRIME = 0x100000001b3ULL; + const uint8_t* p = static_cast(data); + for (size_t i = 0; i < bytes; ++i) { + *h ^= p[i]; + *h *= FNV_PRIME; + } + } + + // Mixes a contiguous container (size prefix + element bytes) into the running FNV hash. + template + static void FnvMixVector(uint64_t* h, const Vec& vec) { + size_t n = vec.size(); + FnvMix(h, &n, sizeof(n)); + if (n > 0) { + FnvMix(h, vec.data(), n * sizeof(vec[0])); + } + } + + // Fingerprint over every input GlyphRunRenderer::BuildTextBlob consumes. When BuildTextBlob is + // changed to read a new field, that field MUST be mixed in here — otherwise the cache may serve + // stale glyphs. + static uint64_t ComputeTextBlobFingerprint(const Text* text, const tgfx::Matrix& inverseMatrix) { + // FNV-1a 64-bit. Collision probability for ~5K entries is ~10^-12, and the glyphSum secondary + // check guards the rare miss. + constexpr uint64_t FNV_OFFSET = 0xcbf29ce484222325ULL; + uint64_t h = FNV_OFFSET; + // 1. Inverse matrix (6 floats). + float matrixValues[6] = {0}; + inverseMatrix.get6(matrixValues); + FnvMix(&h, matrixValues, sizeof(matrixValues)); + // 2. Faux bold / italic flags + bool fauxFlags[2] = {text->fauxBold, text->fauxItalic}; + FnvMix(&h, fauxFlags, sizeof(fauxFlags)); + // 3. GlyphRun count + size_t runCount = text->glyphRuns.size(); + FnvMix(&h, &runCount, sizeof(runCount)); + // 4. Per-run geometry. + for (const auto* run : text->glyphRuns) { + if (!run) { + uint8_t nullMarker = 0; + FnvMix(&h, &nullMarker, sizeof(nullMarker)); + continue; + } + const Font* fontPtr = run->font; + FnvMix(&h, &fontPtr, sizeof(fontPtr)); + FnvMix(&h, &run->fontSize, sizeof(run->fontSize)); + FnvMix(&h, &run->x, sizeof(run->x)); + FnvMix(&h, &run->y, sizeof(run->y)); + FnvMixVector(&h, run->glyphs); + FnvMixVector(&h, run->positions); + FnvMixVector(&h, run->xOffsets); + FnvMixVector(&h, run->scales); + FnvMixVector(&h, run->rotations); + FnvMixVector(&h, run->skews); + FnvMixVector(&h, run->anchors); + } + return h; + } + + // Total glyph count across all GlyphRuns. Used as a cheap secondary key check so a 64-bit + // hash collision (already < 1 in 10^12) cannot promote to a wrong-render bug — a mismatched + // glyphSum forces a rebuild rather than reuse. + static size_t SumGlyphCount(const Text* text) { + size_t sum = 0; + for (const auto* run : text->glyphRuns) { + if (run) sum += run->glyphs.size(); + } + return sum; + } + + // Cached wrapper around PrepareTextBlob. Looks up the fingerprint first; on hit reuses the + // TextBlob and per-glyph anchors from the cache. On miss builds via PrepareTextBlob and + // stashes the result. + void prepareTextBlobCached(Text* text, const tgfx::Matrix& inverseMatrix) { + if (!text || !text->glyphData) return; + // Already prepared in this build session (or a previous instance hit the same cache slot). + if (text->glyphData->textBlob) return; + // The runtime-shaping path uses layoutRuns rather than glyphRuns; the fingerprint scheme + // here covers only the embedded glyphRuns path, which is what cocraft exports. Fall back + // to the uncached path for layoutRuns to stay correct. + if (!text->glyphData->layoutRuns.empty() || text->glyphRuns.empty()) { + PrepareTextBlob(text, inverseMatrix); + return; + } + auto fingerprint = ComputeTextBlobFingerprint(text, inverseMatrix); + size_t glyphSum = SumGlyphCount(text); + auto it = _textBlobCache.find(fingerprint); + if (it != _textBlobCache.end() && it->second.glyphSum == glyphSum) { + text->glyphData->textBlob = it->second.textBlob; + text->glyphData->anchors = it->second.anchors; + return; + } + PrepareTextBlob(text, inverseMatrix); + if (text->glyphData->textBlob) { + _textBlobCache[fingerprint] = + TextBlobCacheEntry{glyphSum, text->glyphData->textBlob, text->glyphData->anchors}; + } + } + // Prepare TextBlobs for all Text children of a TextBox by applying inverse matrices. // This must happen at render time (not layout time) so that tgfx's StrokePainter can // detect the Group transform via geometry->matrix changes between apply() and draw(). @@ -745,7 +950,7 @@ class LayerBuilderContext { if (!matrices[i].invert(&inverse)) { continue; } - PrepareTextBlob(childText[i], inverse); + prepareTextBlobCached(childText[i], inverse); } } @@ -1019,11 +1224,8 @@ class LayerBuilderContext { } std::shared_ptr convertText(Text* node) { + prepareTextBlobCached(node, tgfx::Matrix::I()); auto textBlob = node->glyphData->textBlob; - if (textBlob == nullptr) { - PrepareTextBlob(node, tgfx::Matrix::I()); - textBlob = node->glyphData->textBlob; - } if (textBlob == nullptr) { return nullptr; } @@ -1044,6 +1246,13 @@ class LayerBuilderContext { colorSource = convertColorSource(node->color); } if (colorSource == nullptr) { + // Image-backed fills drop out entirely until the underlying image arrives. Falling back to + // an opaque SolidColor here would paint a black placeholder over every shape that uses an + // ImagePattern before its image is resolved via the provider; leaving the fill empty keeps + // those shapes transparent until the image is ready. + if (node->color && node->color->nodeType() == NodeType::ImagePattern) { + return nullptr; + } colorSource = tgfx::SolidColor::Make(); } @@ -1079,6 +1288,13 @@ class LayerBuilderContext { colorSource = convertColorSource(node->color); } if (colorSource == nullptr) { + // Image-backed strokes drop out entirely until the underlying image arrives. Falling back + // to an opaque SolidColor here would paint a black placeholder along every stroke that uses + // an ImagePattern before its image is resolved via the provider; leaving the stroke empty + // keeps those shapes transparent until the image is ready, matching convertFill. + if (node->color && node->color->nodeType() == NodeType::ImagePattern) { + return nullptr; + } colorSource = tgfx::SolidColor::Make(); } @@ -1260,6 +1476,7 @@ class LayerBuilderContext { gradient->setMatrix(ToTGFX(node->matrix)); } } + return gradient; } @@ -1369,32 +1586,20 @@ class LayerBuilderContext { } auto imageNode = node->image; - std::shared_ptr image = nullptr; - if (imageNode->data) { - image = tgfx::Image::MakeFromEncoded(ToTGFXData(imageNode->data)); - } else if (imageNode->filePath.find("data:") == 0) { - image = ImageFromDataURI(imageNode->filePath); - } else if (!imageNode->filePath.empty()) { - // External file path (already resolved to absolute during import) - image = tgfx::Image::MakeFromFile(imageNode->filePath); - } - - if (image) { - _result.binding.set(imageNode, image); - } - + auto image = getOrCreateImage(imageNode); if (!image) { + // Image data is missing for this node (never loaded or decode failed); paint nothing + // rather than falling back to an opaque color that would be visibly wrong. return nullptr; } + _result.binding.set(imageNode, image); auto sampling = tgfx::SamplingOptions(ToTGFX(node->filterMode), ToTGFX(node->mipmapMode)); auto pattern = tgfx::ImagePattern::Make(image, ToTGFX(node->tileModeX), ToTGFX(node->tileModeY), sampling); if (pattern) { _result.binding.set(node, pattern); - if (imageNode) { - _result.binding.trackImage(imageNode, node); - } + _result.binding.trackImage(imageNode, node); pattern->setScaleMode(ToTGFX(node->scaleMode)); if (!node->matrix.isIdentity()) { pattern->setMatrix(ToTGFX(node->matrix)); @@ -1404,6 +1609,50 @@ class LayerBuilderContext { return pattern; } + std::shared_ptr getOrCreateImage(const Image* imageNode) { + auto it = _imageCache.find(imageNode); + if (it != _imageCache.end()) { + return it->second; + } + // Resolution chain. Two camps of sources exist: + // 1. Provider-resolved images (backend textures): already mipmapped at upload time by the + // host's createBackendTexture helper. Re-wrapping with makeMipmapped(true) would force + // tgfx to allocate a parallel mipmapped texture and copy the pixels. Use as-is. + // 2. CPU-decoded images (encoded data, file path, data URI): produced lazily by tgfx + // codecs. Wrap with makeMipmapped(true) so subsequent sampling at non-1:1 scales does + // not re-decode at every zoom level. + std::shared_ptr image = nullptr; + bool isBackendTexture = false; + // Priority 1: query the provider for a platform-decoded image. + auto* provider = _document ? _document->_imageResourceProvider.get() : nullptr; + if (provider && !imageNode->filePath.empty()) { + image = provider->resolveImage(imageNode->filePath); + if (image) { + isBackendTexture = true; + } + } + // Priority 2: fallback to standard decoding chain. + if (!image) { + if (imageNode->data) { + image = tgfx::Image::MakeFromEncoded(ToTGFXData(imageNode->data)); + } else if (imageNode->filePath.find("data:") == 0) { + image = ImageFromDataURI(imageNode->filePath); + } else if (!imageNode->filePath.empty()) { + image = tgfx::Image::MakeFromFile(imageNode->filePath); + } + } + if (image && !isBackendTexture) { + image = image->makeMipmapped(true); + } + // Only memoize successful results. A null entry would cache the absence of a provider- + // resolved image forever, so a later provider update would never take effect after + // invalidation. + if (image) { + _imageCache[imageNode] = image; + } + return image; + } + std::shared_ptr convertTrimPath(const TrimPath* node) { auto trim = tgfx::TrimPath::Make(); trim->setStart(node->start); @@ -1787,6 +2036,7 @@ class LayerBuilderContext { } // Layer filters +#if !PAG_DISABLE_LAYER_FILTERS std::vector> filters; filters.reserve(node->filters.size()); for (const auto& filter : node->filters) { @@ -1798,6 +2048,7 @@ class LayerBuilderContext { if (!filters.empty()) { layer->setFilters(filters); } +#endif // !PAG_DISABLE_LAYER_FILTERS } std::shared_ptr convertLayerStyle(const LayerStyle* node) { @@ -1807,6 +2058,9 @@ class LayerBuilderContext { switch (node->nodeType()) { case NodeType::DropShadowStyle: { +#if PAG_DISABLE_SHADOW_STYLES + return nullptr; +#else auto style = static_cast(node); auto tgfxStyle = tgfx::DropShadowStyle::Make(style->offsetX, style->offsetY, style->blurX, style->blurY, @@ -1817,8 +2071,12 @@ class LayerBuilderContext { _result.binding.set(style, tgfxStyle); bindDropShadowStyleChannels(style); return tgfxStyle; +#endif } case NodeType::InnerShadowStyle: { +#if PAG_DISABLE_SHADOW_STYLES + return nullptr; +#else auto style = static_cast(node); auto tgfxStyle = tgfx::InnerShadowStyle::Make(style->offsetX, style->offsetY, style->blurX, style->blurY, ToTGFX(style->color)); @@ -1828,8 +2086,12 @@ class LayerBuilderContext { _result.binding.set(style, tgfxStyle); bindInnerShadowStyleChannels(style); return tgfxStyle; +#endif } case NodeType::BackgroundBlurStyle: { +#if PAG_DISABLE_BACKGROUND_BLUR_STYLE + return nullptr; +#else auto style = static_cast(node); auto tgfxStyle = tgfx::BackgroundBlurStyle::Make(style->blurX, style->blurY, ToTGFX(style->tileMode)); @@ -1839,6 +2101,7 @@ class LayerBuilderContext { _result.binding.set(style, tgfxStyle); bindBackgroundBlurStyleChannels(style); return tgfxStyle; +#endif } case NodeType::NoiseStyle: { auto style = static_cast(node); @@ -2418,6 +2681,8 @@ class LayerBuilderContext { } } + std::unordered_map>> + _tgfxLayersByPagxLayer = {}; struct PathCacheKey { const PathData* data; float scale; @@ -2436,6 +2701,22 @@ class LayerBuilderContext { LayerBuildResult _result = {}; std::vector, const Layer*, tgfx::LayerMaskType>> _pendingMasks = {}; + // Maps pagx Image node pointers to their tgfx::Image wrappers. The raw pointer keys are stable + // as long as the document's node vector is not structurally modified. If the document undergoes + // structural changes (node additions/removals via notifyChange), callers must call + // invalidateAllImages() to prevent dangling-pointer lookups. + const PAGXDocument* _document = nullptr; + std::unordered_map> _imageCache = {}; + // TextBlob fingerprint cache. Keyed by FNV-1a 64-bit hash over every BuildTextBlob input. + // glyphSum acts as a secondary check guarding against the (vanishingly rare) hash collision. + // Sharing the same TextBlob shared_ptr across many Text nodes is what reclaims the bulk of + // the per-Text glyph-path memory cost we observed at 0% reuse before the cache was added. + struct TextBlobCacheEntry { + size_t glyphSum = 0; + std::shared_ptr textBlob; + std::vector anchors; + }; + std::unordered_map _textBlobCache = {}; bool _needsRuntimeData = false; }; @@ -2471,6 +2752,85 @@ LayerBuildResult LayerBuilder::BuildWithMap(PAGXDocument* document) { return context.buildWithMap(*document); } +// LayerBuilderSession PImpl: owns the LayerBuilderContext and a non-owning pointer back to the +// PAGXDocument so rebuildForFilePath() can re-enumerate Image nodes and affected layers. +// Lifetime guarantee: PAGXView always destroys builderSession before document (both in +// parsePAGX() and in the destructor's member destruction order), so this pointer never dangles. +struct LayerBuilderSession::Impl { + LayerBuilderContext context; + PAGXDocument* document = nullptr; +}; + +LayerBuilderSession::LayerBuilderSession() : impl(std::make_unique()) { +} + +LayerBuilderSession::~LayerBuilderSession() = default; + +LayerBuildResult LayerBuilderSession::build(PAGXDocument* document) { + if (document == nullptr) { + return {}; + } + if (!document->isLayoutApplied()) { + LOGE( + "LayerBuilderSession::build() called before applyLayout(). Call " + "document->applyLayout() first."); + DEBUG_ASSERT(false); + return {}; + } + impl->document = document; + return impl->context.buildWithMap(*document); +} + +void LayerBuilderSession::invalidateAllImages() { + impl->context.invalidateAllImages(); +} + +size_t LayerBuilderSession::rebuildForFilePath(const std::string& filePath) { + if (!impl->document || filePath.empty()) { + return 0; + } + // Evict the cached tgfx::Image for every Image node backing this filePath so the next call + // into convertImagePattern() re-queries the provider for the updated image. + impl->context.invalidateImagesByFilePath(*impl->document, filePath); + + const auto& affectedLayers = impl->document->findLayersByImageFilePath(filePath); + if (affectedLayers.empty()) { + return 0; + } + size_t rebuiltCount = 0; + for (const auto* pagxLayer : affectedLayers) { + auto tgfxLayers = impl->context.getTgfxLayers(pagxLayer); + for (const auto& tgfxLayer : tgfxLayers) { + if (impl->context.rebuildVectorContents(pagxLayer, tgfxLayer.get())) { + ++rebuiltCount; + } + } + } + return rebuiltCount; +} + +std::vector> LayerBuilderSession::getTgfxLayersByImageFilePath( + const std::string& filePath) const { + if (!impl->document || filePath.empty()) { + return {}; + } + const auto& affectedLayers = impl->document->findLayersByImageFilePath(filePath); + if (affectedLayers.empty()) { + return {}; + } + std::vector> result; + for (const auto* pagxLayer : affectedLayers) { + auto tgfxLayers = impl->context.getTgfxLayers(pagxLayer); + result.insert(result.end(), tgfxLayers.begin(), tgfxLayers.end()); + } + return result; +} + +std::vector> LayerBuilderSession::getTgfxLayers( + const Layer* pagxLayer) const { + return impl->context.getTgfxLayers(pagxLayer); +} + LayerBuildResult LayerBuilder::BuildForRuntime(PAGXDocument* document) { if (document == nullptr) { return {}; @@ -2497,22 +2857,26 @@ LayerBuildResult LayerBuilder::BuildCompositionSubtree(const Composition* compos return context.buildSubtree(composition); } -bool LayerBuilder::RefreshLayerInPlace(const Layer* node, RuntimeBinding* binding) { +bool LayerBuilder::RefreshLayerInPlace(const Layer* node, RuntimeBinding* binding, + const PAGXDocument* document) { if (node == nullptr || binding == nullptr) { return false; } LayerBuilderContext context; context.setNeedsRuntimeData(true); + context.setDocument(document); return context.refreshLayerInPlace(node, binding); } std::shared_ptr LayerBuilder::BuildLayerInto(const Layer* node, - RuntimeBinding* binding) { + RuntimeBinding* binding, + const PAGXDocument* document) { if (node == nullptr || binding == nullptr) { return nullptr; } LayerBuilderContext context; context.setNeedsRuntimeData(true); + context.setDocument(document); return context.buildLayerInto(node, binding); } diff --git a/src/renderer/LayerBuilder.h b/src/renderer/LayerBuilder.h index bd5b3b54ef..60ee61fc3d 100644 --- a/src/renderer/LayerBuilder.h +++ b/src/renderer/LayerBuilder.h @@ -295,6 +295,9 @@ struct RuntimeBinding { struct LayerBuildResult { std::shared_ptr root = nullptr; RuntimeBinding binding = {}; + // 1:1 pagx Layer -> tgfx Layer map, exposing only the first copy when a Composition is + // referenced by multiple Layers. Session users that need every copy use getTgfxLayers(). + std::unordered_map> layerMap = {}; /** * Returns the tgfx layer corresponding to the specified PAGX Layer node, or nullptr if the node @@ -362,9 +365,11 @@ class LayerBuilder { * edits without rebuilding the layer tree. * @param node The Layer node to refresh. * @param binding The runtime binding that maps the node to its tgfx::Layer. + * @param document The owning document, used to resolve image resources via the provider. * @return true if the node had a tgfx::Layer in the binding and was refreshed, false otherwise. */ - static bool RefreshLayerInPlace(const Layer* node, RuntimeBinding* binding); + static bool RefreshLayerInPlace(const Layer* node, RuntimeBinding* binding, + const PAGXDocument* document); /** * Builds a single Layer node (and its vector contents and recursive sub-layers) into the supplied @@ -374,9 +379,87 @@ class LayerBuilder { * the whole tree. * @param node The Layer node to build. * @param binding The runtime binding to populate with the node's mapping. + * @param document The owning document, used to resolve image resources via the provider. * @return The new tgfx::Layer for the node, or nullptr if node or binding is null. */ - static std::shared_ptr BuildLayerInto(const Layer* node, RuntimeBinding* binding); + static std::shared_ptr BuildLayerInto(const Layer* node, RuntimeBinding* binding, + const PAGXDocument* document); +}; + +/** + * LayerBuilderSession wraps LayerBuilder with stateful behavior: it retains the build context + * after build() returns so callers can later re-generate a layer's contents when an underlying + * Image resource changes (e.g. progressive upgrade from a low-resolution thumbnail to a full + * version). The class is intended for the progressive image loading flow on WeChat; other + * platforms should keep using the static LayerBuilder::Build / BuildWithMap entry points. + * + * Lifecycle: create a session, call build() once per document (matching parsePAGX+buildLayers + * cycle), and then call rebuildForFilePath() whenever the ImageResourceProvider's state changes + * for a given filePath (new image attached or evicted). Destroying the session releases all + * cached layer/image state. + * + * IMPORTANT: The caller must guarantee that the PAGXDocument passed to build() remains valid + * for the entire lifetime of this session. The session stores a non-owning pointer to the + * document and dereferences it on every rebuildForFilePath() call. Destroy the session before + * (or together with) the document. + */ +class LayerBuilderSession { + public: + LayerBuilderSession(); + ~LayerBuilderSession(); + + LayerBuilderSession(const LayerBuilderSession&) = delete; + LayerBuilderSession& operator=(const LayerBuilderSession&) = delete; + + /** + * Builds the layer tree for the given document. Behaves identically to + * LayerBuilder::BuildWithMap() but keeps the internal context alive for later rebuilds. + * @return The LayerBuildResult (root layer + pagx Layer to tgfx Layer mapping). The mapping + * reflects the first tgfx layer produced for each pagx Layer; callers that need all + * copies (for example to refresh every Composition instance that shares a Layer) + * should use getTgfxLayers() instead. + */ + LayerBuildResult build(PAGXDocument* document); + + /** + * Rebuilds the tgfx vector contents of every layer whose fill/stroke references an + * ImagePattern backed by an Image node whose filePath matches the given value. Call this + * after the ImageResourceProvider's state changes for the path so the renderer re-queries + * the provider and picks up the new tgfx::Image. A single filePath may match multiple Image + * nodes and each Image node may be referenced by multiple Layers, which in turn may have + * been duplicated by Composition instancing; all such copies are refreshed in one call. + * @return The number of tgfx layers whose contents were regenerated. Zero means no layer + * currently references the given filePath (or the document has not been built yet). + */ + size_t rebuildForFilePath(const std::string& filePath); + + /** + * Drops the entire internal image cache. Call this after the document undergoes structural + * node changes (e.g. after notifyChange with node additions or removals) because the + * cache keys are raw Image* pointers that become dangling when nodes are replaced. + */ + void invalidateAllImages(); + + /** + * Returns every tgfx::Layer that was produced for layers referencing the given image file + * path. Combines findLayersByImageFilePath() and getTgfxLayers() into a single call so + * callers do not need to handle internal pagx::Layer pointers. + */ + std::vector> getTgfxLayersByImageFilePath( + const std::string& filePath) const; + + /** + * Returns every tgfx::Layer that was produced for the given pagx Layer during build(). A + * pagx Layer may map to several tgfx layers when its owning Composition is instanced more + * than once; the returned vector preserves build order. + * @return An empty vector when the pagx Layer was not seen during build() (e.g. nullptr, + * a mask-only layer that got skipped, or no build() call has been made yet). + */ + std::vector> getTgfxLayers(const Layer* pagxLayer) const; + + private: + struct Impl; + std::unique_ptr impl; }; } // namespace pagx diff --git a/test/baseline/version.json b/test/baseline/version.json index e8ff61eadc..3c977b7eb3 100644 --- a/test/baseline/version.json +++ b/test/baseline/version.json @@ -8575,7 +8575,7 @@ "frame_9": "60a88e548" }, "NoiseFilterModes": "60a88e548", - "NoiseStyleBlendModeOnImage": "60a88e548", + "NoiseStyleBlendModeOnImage": "bcd2496cb", "NoiseStyleModes": "60a88e548", "NotifyChangeDocumentSize_after": "60a88e548", "NotifyChangeDocumentSize_before": "60a88e548", @@ -8687,7 +8687,7 @@ "html_native_geometry_rectangle": "60a88e548", "html_native_gradient_matrix": "60a88e548", "html_native_group": "60a88e548", - "html_native_image_external_and_base64": "60a88e548", + "html_native_image_external_and_base64": "f235f2140", "html_native_layer_3d_multi_axis": "60a88e548", "html_native_layer_alpha": "60a88e548", "html_native_layer_antialias": "60a88e548", @@ -8757,14 +8757,13 @@ "layout_container_hug": "60a88e548", "layout_container_vertical": "60a88e548", "layout_flex": "60a88e548", - "layout_flex_weights": "60a88e548", "layout_padding_unified": "60a88e548", "layout_textbox_constraint_wrap": "60a88e548", "layout_textbox_explicit_height": "60a88e548", "layout_textbox_flex_sibling": "60a88e548", "layout_textbox_nested_wrap": "60a88e548", "skills_arrow_line": "60a88e548", - "skills_avatar_with_circular_clip": "60a88e548", + "skills_avatar_with_circular_clip": "bcd2496cb", "skills_bar_chart": "60a88e548", "skills_button_badge": "60a88e548", "skills_card_grid": "60a88e548", @@ -8789,7 +8788,7 @@ "spec_app_icons": "451bc13e", "spec_clip_to_bounds": "60a88e548", "spec_color_source_coordinates": "60a88e548", - "spec_complete_example": "60a88e548", + "spec_complete_example": "f235f2140", "spec_composition": "60a88e548", "spec_conic_gradient": "60a88e548", "spec_constraint_opposite_edge_stretch": "60a88e548", @@ -8807,7 +8806,7 @@ "spec_group": "60a88e548", "spec_group_isolation": "625e411a", "spec_group_propagation": "60a88e548", - "spec_image_pattern": "60a88e548", + "spec_image_pattern": "f235f2140", "spec_layer": "60a88e548", "spec_layer_filters": "60a88e548", "spec_layer_styles": "60a88e548", @@ -8834,12 +8833,12 @@ "spec_text_modifier": "60a88e548", "spec_text_path": "60a88e548", "spec_trim_path": "60a88e548", - "svg_Baseline": "60a88e548", - "svg_ColorPicker": "60a88e548", - "svg_Guidelines": "60a88e548", + "svg_Baseline": "f235f2140", + "svg_ColorPicker": "f235f2140", + "svg_Guidelines": "f235f2140", "svg_Overview": "60a88e548", "svg_Switch": "60a88e548", - "svg_UIkit": "60a88e548", + "svg_UIkit": "f235f2140", "svg_blur": "60a88e548", "svg_complex1": "60a88e548", "svg_complex2": "60a88e548", @@ -8847,15 +8846,15 @@ "svg_complex4": "60a88e548", "svg_complex5": "60a88e548", "svg_complex6": "60a88e548", - "svg_complex7": "60a88e548", + "svg_complex7": "f235f2140", "svg_customData": "60a88e548", - "svg_displayp3": "60a88e548", - "svg_drawImageRect": "60a88e548", + "svg_displayp3": "f235f2140", + "svg_drawImageRect": "bcd2496cb", "svg_duplicatePaths": "60a88e548", - "svg_jpg": "60a88e548", + "svg_jpg": "bcd2496cb", "svg_mask": "60a88e548", "svg_path": "60a88e548", - "svg_png": "60a88e548", + "svg_png": "bcd2496cb", "svg_radialGradient": "60a88e548", "svg_refStyle": "60a88e548", "svg_widegamut": "60a88e548", diff --git a/vendor.json b/vendor.json index 00c84d7556..beefb44afd 100644 --- a/vendor.json +++ b/vendor.json @@ -39,7 +39,8 @@ ], "arguments": [ "-DBUILD_STATIC=TRUE", - "-DCMAKE_POLICY_VERSION_MINIMUM=3.5" + "-DCMAKE_POLICY_VERSION_MINIMUM=3.5", + "-DCMAKE_CXX_FLAGS=\"-w\"" ], "includes": [ "${SOURCE_DIR}/src/rttr",