From 6d4cd105051934f169c238bbc1d98f07b86f0d7d Mon Sep 17 00:00:00 2001 From: 789987777777 <3606995509@qq.com> Date: Thu, 3 Sep 2026 20:54:40 +0800 Subject: [PATCH 1/9] docs: add spreadsheet provenance spec --- AGENTS.md | 834 ++++++++++++++++++++++++++++ README.md | 10 + docs/spreadsheet-provenance-plan.md | 173 ++++++ docs/spreadsheet-provenance-spec.md | 236 ++++++++ 4 files changed, 1253 insertions(+) create mode 100644 AGENTS.md create mode 100644 docs/spreadsheet-provenance-plan.md create mode 100644 docs/spreadsheet-provenance-spec.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..38054c87 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,834 @@ +--- +name: vibecoding-engineering +description: 用证据驱动、可追踪、可审查的流程指导 Agent 完成功能、Bug、架构和长期软件工程任务。 +metadata: + short-description: Evidence-driven software engineering for AI agents +--- + +# Vibecoding Engineering Skill + +## 1. 目标 + +把一次 AI 编程任务从“给出目标,等待 Agent 声称完成”变成可理解、可追踪、可验证、可审查、可回滚、可交接的工程过程: + +```text +Intent + ↓ +Research + ↓ +Clarify + ↓ +Understand the code + ↓ +Decision + ↓ +Plan + Acceptance Criteria + ↓ +Implement + ↓ +Observe + ↓ +Verify + ↓ +Evidence + ↓ +Review + ↓ +Done +``` + +Agent 的职责不是只生成代码,而是帮助建立能够及时发现、定位、纠正错误的工作系统。任何“完成”声明都必须由可独立检查的证据支持。 + +## 2. 适用范围 + +对以下任务使用本 Skill,并按任务规模缩放深度: + +- 新功能、产品流程和 API; +- Bug 诊断与修复; +- 架构设计、模块拆分和重构; +- UI 调整、交互实现和视觉还原; +- 测试补充、代码 Review 和长期任务接管; +- 需要跨文件、跨组件或跨外部系统追踪的工作。 + +极小且无风险的单行修改可以缩短流程,但不得跳过必要的理解、范围控制和验证。 + +## 3. 硬规则 + +### 3.1 禁止许愿式编程 + +不要把以下模糊指令直接当作完整规格: + +- “把这个功能做好。” +- “把这个 Bug 修掉。” +- “优化一下架构。” +- “按参考项目实现。” +- “你自己检查,没问题就提交。” + +不要替请求方自行补全关键需求、架构、边界、验收或风险。必须把任务转化为可观察的中间产物:调研结论、事实、决策、计划、验收标准、修改范围和验证证据。 + +### 3.2 先读代码,再改代码 + +遵循 `Read Before Write` 与 `Understand Before Modify`。在理解入口、调用链、数据流、现有测试和影响范围之前,不得开始实质性实现。 + +### 3.3 模糊就澄清 + +只要关键行为、边界、数据、约束或完成标准存在多种合理解释,就暂停实现并提问。不得用“通常应该是……”代替用户决策。 + +### 3.4 不懂不接受 + +如果方案、架构或修改理由无法用自己的话解释清楚,就不能批准实施。Agent 必须解释职责、数据流、依赖、收益、复杂度、风险和替代方案,并指出仍不确定的地方。 + +### 3.5 事实优先于叙述 + +代码、测试、Git Diff、日志、实际运行行为、浏览器状态和截图是验证材料;Agent 的“已经完成”“应该没问题”只是 Claim,不是 Evidence。 + +### 3.6 严格控制范围 + +禁止借机重构无关模块、顺手改风格、替换依赖或扩大 API。发现相邻问题时记录为独立 Issue/任务,除非获得明确授权。 + +### 3.7 复杂度必须匹配问题 + +优先采用当前项目中最简单、可解释、可验证的方案。不要为了体现模式而增加层、抽象或依赖;也不要为了省事把不相关职责塞进巨型文件。 + +### 3.8 Handoff 不是权威结论 + +交接文档、旧对话和 Agent 推理都是历史信息。新 Agent 必须用真实代码、Git 状态、测试和日志核验,不得盲信。 + +## 4. 何时停止并澄清 + +遇到以下任一情况,停止实现,先报告已知事实、未知点和需要的决策: + +- 目标用户、成功行为或明确的“不做什么”不清楚; +- 成功、失败、空数据、加载、权限、重复提交或超时行为未定义; +- Spec、架构文档、代码和测试互相矛盾; +- 无法确定入口、真实调用链、数据转换或副作用; +- 方案需要新增依赖、改变公共契约、修改数据模型、权限或高风险配置,但没有批准; +- Debug 只有现象,没有可重复步骤或证据; +- 根因仍只是猜测; +- 计划没有可操作的验收标准或测试方法; +- 现有未提交 Diff 使修改归属或基线不清楚; +- 预计修改会超出允许文件或模块; +- Review 发现关键风险尚未解决。 + +可以继续做只读探索、收集证据和提出选项;不能把探索性猜测伪装成实现。 + +## 5. 任务开始前检查 + +开始任何非平凡任务时,依次完成: + +1. **分类任务**:Feature、Bug、Refactor、Review、Visual、Long-task 或 Sub-agent。 +2. **确认目标与边界**:记录要解决的问题、明确不做的内容、目标用户和成功结果。 +3. **检查仓库状态**:确认当前分支、未提交 Diff、最近相关提交、测试基线和是否存在并行修改。 +4. **读取导航文档**:至少查看 README、项目规则、相关 Architecture、Spec、API 和近期 ADR;只读取与任务相关的部分。 +5. **定位真实代码**:搜索入口、核心函数、数据模型、配置、测试、调用方和被调用方。 +6. **识别风险**:标记公共接口、持久化数据、认证授权、并发、外部调用、文件格式和不可逆操作。 +7. **建立代码地图**:至少对关键链路形成 File Map、Call Map、Data Flow Map 和 Impact Map。 +8. **写出计划与验收标准**:在修改前说明步骤、允许范围、验证方式和停止条件。 +9. **确认基线**:尽可能先运行相关测试或记录当前已知失败,避免把旧问题误判为本次回归。 + +## 6. 调研流程:先调研,后设计,再实现 + +重要功能、架构或技术选型必须先调研。调研的目的不是收集链接,而是减少重复造轮子、错误设计和过度实现。 + +### 6.1 竞品与同类产品调研 + +检查: + +- 同类产品是否已经解决相同问题; +- 用户完成目标的核心流程; +- 行业内已经形成的交互惯例; +- 值得借鉴的设计与明显缺陷; +- 当前项目真正需要的深度,是否值得复杂实现。 + +把“别人怎么做”与“本项目决定怎么做”分开记录。竞品行为是参考,不是自动接受的需求。 + +### 6.2 开源与技术方案调研 + +检查 GitHub、官方文档和成熟库中是否存在可复用的: + +- 完整项目或库; +- 单个模块、算法、解析器、导出器或 UI 组件; +- 协议实现、数据结构和架构思路。 + +不要因为只需要一个模块就复制整个项目。对候选方案记录: + +- License 是否允许当前使用方式; +- 维护活跃度、最近更新和 Issue 状况; +- 代码质量、安全性和依赖复杂度; +- 与当前技术栈、架构和数据模型的兼容性; +- 二次开发、升级和长期维护成本; +- 接入成本是否高于自己实现。 + +结论只能是“采用、部分借鉴、暂不采用或自行实现”,并说明理由。 + +## 7. 需求澄清与认知对齐 + +### 7.1 必须澄清的问题 + +至少逐项确认: + +**目标** + +- 到底要做什么? +- 明确不做什么? +- 谁使用,为什么需要? + +**行为** + +- 用户每一步操作后发生什么? +- 成功、失败、取消、重试和重复操作如何表现? +- Loading、Empty、Error、Disabled、Permission Denied 如何表现? + +**数据** + +- 输入、输出和字段是什么? +- 数据从哪里来,谁创建、谁修改、谁消费? +- 生命周期、校验、默认值和兼容性是什么? + +**技术** + +- 复用现有模块还是新建模块? +- 同步还是异步? +- 是否需要缓存、队列、新依赖或公共接口变化? + +**边界** + +- 网络失败、超时、空数据、权限不足、并发和重复提交怎么办? +- 第三方或外部系统返回异常怎么办? + +**验收** + +- 哪些可观察结果出现时才算完成? +- 哪些测试、操作或截图能够证明完成? + +### 7.2 澄清循环 + +使用以下循环,直到关键歧义消失: + +```text +发现模糊 + ↓ +提出具体问题与可选方案 + ↓ +得到决策 + ↓ +写入 Spec / ADR / Task + ↓ +继续检查新的歧义 +``` + +不要只问“这样可以吗”。应说明选项、影响、风险和自己的不确定点,让决策可以被审查。 + +### 7.3 不懂不接受的检查 + +对于任何设计,要求 Agent 能回答: + +- 每一层和每个模块分别负责什么? +- 数据从入口到输出怎样流动? +- 为什么需要该层,为什么不能用更简单的方案? +- 删除某一层会发生什么? +- 哪些地方最容易出错? +- 以后扩展一个相邻功能需要改哪里? +- 该设计引入了哪些额外复杂度和维护成本? + +只有在请求方或负责决策的人能够不看原文复述方案、判断取舍并明确批准后,才能实施。 + +## 8. 上下文来源与 Source of Truth + +按任务需要读取相关上下文,但不要用旧对话取代真实项目状态。各类信息的职责如下: + +- **批准的 Spec 与 Acceptance Criteria**:定义预期行为和范围; +- **Architecture 与 ADR**:记录已确认的结构和决策理由; +- **真实代码、配置与测试**:描述当前实际实现; +- **Git 分支、Diff、Commit 与 PR**:描述变更边界和历史; +- **运行结果、日志、浏览器状态和截图**:描述可观察行为; +- **Handoff 与对话**:提供历史背景、尚未解决的问题和待核验建议。 + +如果文档与代码冲突,报告冲突并回到源头核验;不要默默修改文档或代码来掩盖冲突。每个任务都要明确当前采用的 Source of Truth,且不能把 Agent 的自然语言声明当作权威。 + +## 9. 代码地图与真实执行链 + +不要求一开始逐行看懂所有代码,但必须让项目不再是黑盒。对关键功能持续维护四张地图。 + +### 9.1 File Map + +回答“每个关键文件负责什么、属于哪一层、对外暴露什么”。 + +```text +api.py → HTTP 入口与请求/响应适配 +auth_service.py → 认证业务规则 +user_repository.py → 用户数据访问 +token.py → Token 生成与验证 +``` + +### 9.2 Call Map + +回答“谁调用谁、在哪个条件下调用、错误如何返回”。 + +```text +login() + ↓ +authenticate_user() + ↓ +get_user() + ↓ +verify_password() + ↓ +issue_token() +``` + +### 9.3 Data Flow Map + +回答“数据经过哪些格式、校验、转换和状态变化”。 + +```text +HTTP JSON + ↓ +Request Model + ↓ +Service + ↓ +Database Model + ↓ +Response DTO + ↓ +JSON +``` + +### 9.4 Impact Map + +回答“修改一个位置,哪些消费者、输出、测试和外部行为可能受影响”。 + +```text +修改 Markdown Table Parser + ↓ +可能影响 +├── DOCX Export +├── PDF Export +├── Preview +└── Table Validation +``` + +### 9.5 跨文件与跨组件链路 + +不能只说“A.py 是解析器”,还要追踪输入如何穿过文件、模块、格式和外部处理环节。例如: + +```text +Markdown 表格 + ↓ +docx_out.py 解析 Markdown + ↓ +生成中间结构与 OOXML 表格 + ↓ +生成 .docx + ↓ +Microsoft Word + ↓ +Word Layout Engine 自动排版 +``` + +最终结果异常时,问题可能在输入、Parser、中间结构、OOXML 属性、生成逻辑或外部布局引擎;不要只盯着最后显示结果对应的文件。 + +### 9.6 架构图必须来自 Code Trace + +禁止“看目录树、猜分层、直接画图”。画流程图或架构图前执行: + +```text +Explore Codebase + ↓ +Locate Entry Point + ↓ +Trace Functions and Calls + ↓ +Trace Data Transformations + ↓ +Trace Side Effects and External Boundaries + ↓ +Verify Against Code / Tests / Logs + ↓ +Draw Diagram +``` + +图的粒度随问题变化: + +- 项目总览:Frontend → Backend → Database 等高层边界; +- 功能流程:入口 → 函数 → 模块 → 数据转换 → 输出; +- Debug:具体函数、状态、序列化、文件格式和外部布局行为。 + +如果不能确认 A 是否调用 B,就回到代码搜索和测试验证;不为让图看起来完整而编造箭头。 + +## 10. 先读代码再提出修改方案 + +复杂任务先执行 Explore,不修改代码。探索报告至少包括: + +- 当前功能流程; +- 入口、核心函数和关键文件; +- 调用链与数据流; +- 数据模型、配置和外部依赖; +- 现有测试与已知限制; +- 与任务有关的代码位置; +- 可能受影响的消费者; +- 仍然不确定的事实; +- 建议的修改范围及理由。 + +只有探索结果与真实代码相符、关键不确定点已经处理,才进入 Plan 和 Implement。 + +## 11. 代码分层与架构约束 + +采用与项目规模匹配的职责边界。常见方向如下: + +```text +Presentation / API + ↓ +Application / Service + ↓ +Domain + ↓ +Repository / Data + ↓ +Infrastructure +``` + +不是所有项目都需要全部层。必须遵守: + +- 每个模块有清晰、单一的主要职责; +- 公共能力集中维护,避免复制粘贴; +- 依赖方向明确,避免循环依赖; +- 接口和数据契约清楚; +- 巨型文件、高耦合和职责混杂必须有充分理由; +- 先检查现有分层,再决定新代码放置位置; +- 新抽象必须解决真实问题,并能通过测试验证; +- 不要为了分层而分层,也不要以“项目小”为由让所有逻辑混在一起。 + +## 12. 文档分层 + +不要把整个项目塞进一个 README。按职责维护: + +- **README**:项目是什么、如何启动、基本导航; +- **ARCHITECTURE**:系统结构、模块边界、数据流和关键组件; +- **SPEC**:功能目标、范围、行为和 Acceptance Criteria; +- **API**:接口输入、输出、错误、契约和兼容性; +- **ADR / DECISIONS**:决策、原因、候选方案和被否决的方案; +- **TASK / PLAN**:当前任务、步骤、状态、风险和验证; +- **AGENT RULES**:允许与禁止的目录、操作、规范和确认点; +- **HANDOFF**:长任务的状态、事实、决策链、尝试记录和下一步。 + +对话用于消除歧义,文档用于保存长期事实,代码用于保存真实实现。重要决策不能只留在聊天记录里。 + +## 13. 长会话 Handoff + +出现以下信号时不要硬撑:上下文过长、早期决策被遗忘、重复讨论、任务边界漂移、已解决问题反复出现,或 Agent 开始依赖不完整记忆。此时: + +1. 记录当前状态并生成 Handoff; +2. 保存已完成的验证结果和未解决问题; +3. 提交或保存一个清晰的当前 Git 状态; +4. 开启新会话; +5. 由新 Agent 重新建立认知后继续。 + +Handoff 必须回答“为什么走到这里”,不能只写“接下来做 X”。至少包含: + +- 任务目标与明确范围; +- Spec、Architecture、ADR、Issue、Test 等 Source of Truth 的位置; +- 已完成、进行中和未完成事项; +- 关键文件、模块、入口和函数; +- 当前 File/Call/Data/Impact Map 的摘要; +- 历史决策链:考虑过什么、为何否决、为何采用当前方案; +- 已尝试方案,尤其是失败方案及失败原因; +- 通过代码、测试、日志或运行结果确认的事实; +- 尚未验证的假设,明确标记为假设; +- 已知 Bug、风险和阻塞点; +- Branch、Commit、PR 和当前 Diff; +- 已运行的测试、通过项、失败项和未测试项; +- 下一步建议,并明确“建议不是不可质疑的结论”。 + +## 14. 新会话接管 + +新 Agent 按以下顺序接管,不要直接照抄旧结论: + +```text +快速读取项目基础文档与导航 + ↓ +根据任务范围定位真实代码 + ↓ +阅读入口、调用链、数据流、测试与当前 Diff + ↓ +阅读 Handoff,理解历史与决策链 + ↓ +回到代码、Git、测试和日志核验 Handoff + ↓ +输出自己的理解与不确定点 + ↓ +请求确认或澄清 + ↓ +继续 Plan / Implement / Debug +``` + +接管报告必须能说明: + +- 当前目标是什么; +- 系统现在如何工作; +- 为什么走到当前状态; +- 已完成什么; +- 卡在哪里; +- 哪些是已证实事实; +- 哪些仍是假设; +- 下一步是什么以及如何验证。 + +## 15. 子代理委派 + +子代理不是主 Agent 的上下文复制品。先确定角色,再决定传递什么信息。 + +### 15.1 执行型子代理 + +当技术决策已经完成、子代理的任务只是按方案实现时,提供足够的决策上下文: + +- 任务目标、Spec 和已批准方案; +- Architecture、ADR 和相关真实代码; +- 允许修改、限制修改和禁止修改的范围; +- 接口契约、数据约束和不可改变的行为; +- Acceptance Criteria、测试方法和完成证据格式; +- 已知风险与必须保持的兼容性。 + +要求返回: + +- 修改文件与每个修改的目的; +- 关键实现选择; +- 完整 Diff 摘要; +- 测试、构建、运行和截图结果; +- 未完成项、风险和需要主 Agent 决策的事项。 + +### 15.2 调查、Debug、Review 型子代理 + +这类任务的价值来自独立判断。使用“最小充分上下文”: + +- 提供客观现象、目标、必要约束、相关代码范围和输出格式; +- 先让子代理读真实代码、测试和日志; +- 先让它独立描述系统如何工作并形成判断; +- 暂缓提供主 Agent 的怀疑、Root Cause、偏好、长篇推理和拟定修复; +- 子代理提交独立结论与证据后,再提供主 Agent 方案; +- 要求双方比较,再用实验、测试或日志验证; +- 未完成对齐前,不授权修改。 + +刻意避免 Anchoring(锚定效应)与 Confirmation Bias(确认偏误):不要让子代理只寻找支持主 Agent 预设结论的证据。 + +委派顺序: + +```text +Objective Facts + ↓ +Minimum Sufficient Context + ↓ +Relevant Code Scope + ↓ +Child Reads Code + ↓ +Independent Analysis + ↓ +Evidence and Own Conclusion + ↓ +Compare With Parent Analysis + ↓ +Experiment / Test + ↓ +Decide Whether to Execute +``` + +共享事实,不急着共享观点;共享约束,不急着共享结论。不要把子代理变成只寻找证据支持主 Agent 预设答案的工具。 + +## 16. Git 分支、Commit 与 PR + +把 Git 当作变更控制与审查系统,而不只是代码备份。 + +### 16.1 分支 + +- `main`:稳定、可运行、可作为发布基线的分支;原则上不直接让 Agent 随意修改; +- `feat/`:新功能,如 `feat/user-login`; +- `fix/`:普通 Bug,如 `fix/login-redirect`; +- `hotfix/`:紧急修复,如 `hotfix/payment-timeout`; +- `refactor/`:不改变预期行为的重构,如 `refactor/auth-service`; +- `docs/`:文档修改; +- `chore/`:依赖或工程维护; +- `test/`:主要增加或修改测试。 + +一个分支对应一个明确任务和一个上下文边界。不同功能、修复和重构分开;发现额外问题时开独立任务和分支。 + +### 16.2 Commit + +Commit 必须小、清晰、单一目的、可回滚,并说明实际变化: + +```text +feat(auth): add login endpoint +fix(docx): preserve table column width +test(auth): add expired token case +``` + +避免 `update stuff` 这类无法审查的描述。大修改前先保存清晰基线,避免把多个不相关目的混进一个 Commit。 + +用 Git Diff、Log、Revert 和 Cherry-pick 检查、比较或恢复变更;涉及历史重写或可能丢失工作的 Reset 等操作,先确认目标和授权。 + +### 16.3 PR + +不要“AI 写完就直接合并”。标准流程是: + +```text +明确任务分支 + ↓ +Implement + ↓ +Test and Verify + ↓ +Commit + ↓ +Review Diff + ↓ +PR + ↓ +Independent Review / CI + ↓ +确认验收标准全部满足 + ↓ +合并到 main +``` + +PR 至少说明: + +- **Why**:为什么改; +- **What**:改了什么; +- **How**:怎样实现; +- **Risk**:风险与未覆盖边界; +- **Test**:运行了什么、结果如何; +- **Screenshot**:UI 变化时附关键状态截图。 + +Review 看 Spec、Diff、测试和真实行为,不以 Agent 的自我评价为依据。尽量让独立 Reviewer 不先阅读 Implementer 的长篇辩护。 + +较大的任务可以按 Planner → Implementer → Reviewer → Tester 分工,但角色分工不能替代主 Agent 的最终验收。 + +## 17. 规划阶段定义 Done + +Plan 必须同时写出验收标准和测试策略: + +```text +需求 + ↓ +Spec + ↓ +Plan + Acceptance Criteria + Test Strategy + ↓ +Implement + ↓ +Verify +``` + +验收标准要描述可观察行为,而不是实现愿望。例如登录功能至少应明确: + +- 正确账号密码:登录成功并进入目标页面; +- 错误密码:显示明确错误,不发生错误跳转; +- 空用户名:阻止提交并提示原因; +- Token 失效:按约定返回登录流程; +- 刷新页面:登录状态按约定保持或失效; +- 手机尺寸:关键内容、交互和错误状态不出现明显布局问题。 + +将验证分成四类: + +- **Correctness Verification**:逻辑、状态、数据转换和错误处理是否正确; +- **Acceptance Verification**:是否实现了用户真正批准的行为; +- **Regression Verification**:相关已有流程是否仍然正常; +- **Visual Verification**:实际界面、布局、层级、状态和可读性是否正确。 + +## 18. 截图与浏览器验证 + +UI 任务不能只凭代码、构建成功或 Agent 描述判断完成。执行: + +```text +修改 + ↓ +启动真实应用 + ↓ +打开目标页面 + ↓ +执行真实交互 + ↓ +截图关键状态 + ↓ +观察并对照需求或参考图 + ↓ +修正偏差 + ↓ +再次运行与截图 +``` + +适用时覆盖: + +- Default; +- Loading; +- Success; +- Error; +- Empty; +- Disabled; +- Mobile; +- Desktop。 + +有 Figma、设计稿或参考产品时,执行 `Reference Screenshot → Implementation Screenshot → Compare → Fix`。有浏览器能力时还检查 Console、Network、DOM、Runtime Error 和 API Response。 + +## 19. Debug:证据驱动,禁止 Guess-and-Patch + +严格按以下顺序处理: + +```text +Reproduce + ↓ +Evidence + ↓ +Narrow + ↓ +Hypothesis + ↓ +Validate + ↓ +Root Cause + ↓ +Minimal Fix + ↓ +Regression +``` + +### 19.1 每一步的要求 + +1. **Reproduce**:记录稳定、最小、可重复的步骤、输入、前置状态和实际结果。 +2. **Evidence**:收集日志、堆栈、Network、状态快照、截图、数据库/文件结果和相关测试输出。 +3. **Narrow**:用调用链、数据流、时间顺序和边界条件缩小到具体模块或转换环节。 +4. **Hypothesis**:提出一个或多个可证伪的根因假设,说明每个假设的证据和缺口。 +5. **Validate**:用最小实验、日志、测试或对照组验证,不把相关性当因果。 +6. **Root Cause**:形成有证据支持的根因判断;如果仍不能支持,继续调查,不进入修复。 +7. **Minimal Fix**:只改根因相关位置,避免顺手重构附近模块。 +8. **Regression**:重跑 Bug Reproduction Test、既有测试和相关流程测试;确认没有破坏相邻行为。 + +能做到时,先写或构造一个会失败的 Bug Reproduction Test: + +```text +Test Fails + ↓ +Minimal Fix + ↓ +Test Passes + ↓ +Related Regression Tests +``` + +允许为收集证据添加临时、可识别、可撤销的诊断记录;诊断代码不能被误当成正式修复。 + +## 20. 修改范围控制 + +每次任务在 Plan 中写清: + +- **Allowed**:允许修改的文件、模块和契约; +- **Restricted**:原则上不动,必须说明理由后再改; +- **Forbidden**:禁止的目录、秘密、不可逆或高风险操作。 + +尤其对数据库迁移、认证授权、公共 API、持久化格式、第三方集成和破坏性命令设置额外检查。完成后检查: + +- Diff 是否只包含任务相关文件; +- 是否出现无关重构、格式化、依赖或命名变化; +- 是否引入未批准的公共行为; +- 是否新增了需要单独审查的风险; +- 是否可以用最小修改解释全部变化。 + +## 21. Evidence of Done + +提交完成声明时,附上适用的证据,而不是只写“完成”: + +```text +Acceptance Criteria ✓ / 部分 / 未完成 +Correctness Tests ✓ / 部分 / 未运行 +Acceptance Flow ✓ / 部分 / 未运行 +Regression Tests ✓ / 部分 / 未运行 +Lint / Type Check ✓ / 部分 / 未运行 +Build ✓ / 部分 / 未运行 +Runtime Verification ✓ / 部分 / 未运行 +Screenshot / Visual Check ✓ / 部分 / 不适用 +Console / Network Check ✓ / 部分 / 不适用 +Diff Scope Review ✓ / 未完成 +Independent Review ✓ / 未完成 +``` + +测试证据按项目适用性选择,可包括 Unit、Integration、API、E2E、Lint、Type Check、Build 和 CI。每项至少注明运行方式、结果、关键输出或证据位置。明确列出未验证项、已知限制、剩余风险和下一步,不要用绿色勾选掩盖空白。 + +## 22. 完整 Feature SOP + +1. 读取任务、项目规则、相关文档与 Git 状态。 +2. 做竞品和开源方案调研,记录采用与不采用的理由。 +3. 澄清目标、非目标、行为、数据、边界和验收标准。 +4. 先读真实代码,定位入口、调用链、数据流、测试和影响范围。 +5. 输出 File/Call/Data/Impact Map 与当前理解。 +6. 解释候选设计,比较简单方案与复杂方案;对未知点停止并澄清。 +7. 更新 Spec/ADR/Task,确定允许修改范围。 +8. 创建以任务命名的分支。 +9. 写出 Plan、Acceptance Criteria 和 Test Strategy。 +10. 按最小范围实现,保持代码分层和现有契约。 +11. 运行 Correctness、Acceptance、Regression 验证。 +12. 对 UI 启动真实应用,执行关键交互并截图;检查浏览器状态。 +13. 检查 Diff,移除无关变更,补充 Evidence of Done。 +14. Commit、创建 PR、接受独立 Review 和 CI 检查。 +15. 处理 Review 反馈并重新验证;满足验收后才合并。 + +## 23. 完整 Bug SOP + +1. 阅读问题描述、相关代码、测试、最近 Diff 和项目规则。 +2. 不修改正式逻辑,先稳定复现。 +3. 收集日志、堆栈、Network、状态、截图和测试输出。 +4. 根据真实调用链和数据流缩小范围。 +5. 提出可证伪假设,明确证据与缺口。 +6. 用最小实验或失败测试验证假设。 +7. 形成证据支持的 Root Cause;不能支持时停止修复并继续调查。 +8. 确认允许修改范围,只做 Minimal Fix。 +9. 运行 Bug Reproduction Test、既有测试和相关流程测试。 +10. 对 UI 或运行时问题再次执行真实操作、截图、Console/Network 检查。 +11. 审查 Diff 是否包含无关改动,记录证据和剩余风险。 +12. Commit 并通过 PR Review;不要把猜测、临时日志或未验证方案当作完成。 + +## 24. 完整 Long-task SOP + +1. 在开始时建立任务 Spec、Task Plan、代码地图、验收标准和当前 Git 基线。 +2. 每完成一个可验证单元就记录状态、决策、测试和 Diff。 +3. 发现新问题时更新文档;区分事实、假设、决策和建议。 +4. 当会话变长或出现 Context Drift 时停止继续堆对话。 +5. 写完整 Handoff,包含目标、Source of Truth、代码位置、决策链、尝试、事实、假设、风险、Git 和测试状态。 +6. 保存清晰的当前 Git 状态。 +7. 开启新会话;新 Agent 先读相关真实代码和测试,再读 Handoff,并回到代码核验。 +8. 新 Agent 输出自己的理解并完成认知对齐后,再继续 Plan、Implement 或 Debug。 + +## 25. 完整 Sub-agent SOP + +1. 明确子代理角色:执行、调查、Debug、Review 或测试。 +2. 定义 Objective、输出格式、允许范围、禁止范围和验证标准。 +3. 按角色提供上下文: + - 执行型:提供已批准方案和充分决策信息; + - 调查/Debug/Review 型:只提供最小充分事实、代码范围和约束,隔离主 Agent 观点。 +4. 要求子代理先读真实代码、测试、Diff 和相关文档,先写自己的系统理解。 +5. 调查型子代理先独立分析,再接收主 Agent 的怀疑或方案。 +6. 收集其结论、证据、未知点和替代解释。 +7. 主 Agent 与子代理结论进行 Compare,并用实验或测试裁决。 +8. 只有执行方案已经对齐、范围明确且验收标准可验证时,才授权修改。 +9. 子代理返回 Diff、测试、运行结果、截图和剩余风险。 +10. 主 Agent 独立 Review;子代理的结论不能替代最终验收。 + +## 26. 最终执行检查 + +在结束前问自己: + +- 我是否做了必要调研,而不是从零猜测? +- 关键歧义是否已经澄清并写入文档? +- 我是否先读了真实代码并追踪了执行链? +- File/Call/Data/Impact Map 是否足以解释本次变化? +- 方案是否被理解、比较并批准? +- Plan 是否提前定义了可观察的 Done? +- 修改是否局限在允许范围? +- Debug 是否有证据支持 Root Cause? +- 是否完成了 Correctness、Acceptance、Regression 和适用的 Visual Verification? +- 是否用截图、浏览器、日志或测试结果证明真实行为? +- Git Diff、Commit、PR 和文档是否足以让别人复查和接手? +- 是否仍有未验证项、未知风险或需要用户决定的地方? + +如果任一关键问题答不上来,不要用“应该没问题”结束;报告缺口并继续验证或请求澄清。 + +> 最重要的原则:不要许愿式编程。不要期待 Agent 一次猜对所有事情;把工作拆成能被理解、观察、验证、审查、纠正和回滚的工程步骤。 diff --git a/README.md b/README.md index 760d3f18..8e81132a 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,16 @@ let markdown = anydoc::to_markdown_bytes(&bytes, anydoc::Format::Csv)?; let document = anydoc::to_document(&bytes, None)?; ``` +### Spreadsheet provenance + +The structured model returned for `.xlsx`, `.xlsm`, `.xlsb`, `.xls`, and `.ods` +tables keeps worksheet identity and source coordinates. `Table::source` carries +the zero-based worksheet index and name plus an inclusive source range; each +spreadsheet origin `Cell::source` carries its own inclusive range. Coordinates +are zero-based, so `C3` is `{ row: 2, column: 2 }`. Merged origins retain their +full source rectangle even when hidden rows or columns change the normalized +grid. Non-spreadsheet tables and CSV keep these optional fields unset. + ## OCR anydoc reads text-based PDFs locally but does no OCR, so a PDF with scanned or image-only pages fails with `NeedsOcr`. Opt in and those documents go to [Firecrawl Parse](https://firecrawl.dev/parse), which OCRs them and returns the same Markdown. No signup needed; set `FIRECRAWL_API_KEY` for higher limits. diff --git a/docs/spreadsheet-provenance-plan.md b/docs/spreadsheet-provenance-plan.md new file mode 100644 index 00000000..0fcdef5b --- /dev/null +++ b/docs/spreadsheet-provenance-plan.md @@ -0,0 +1,173 @@ +# Spreadsheet provenance implementation plan + +This plan implements `docs/spreadsheet-provenance-spec.md` on the +`codex/spreadsheet-provenance` branch. + +## Allowed scope + +Modify only the shared document model, spreadsheet parsers, ODS parser, +bindings, generated binding declarations, relevant tests, and the two +spreadsheet provenance documents. + +Do not change Markdown rendering, format detection, hidden-content policy, +resource limits, unrelated parsers, the CSR project, or dependencies unless a +test proves a narrowly scoped change is required. + +## Commit plan + +### Commit 1 — public model and source-coordinate primitives + +Files: + +- `src/model/source.rs` (new); +- `src/model/mod.rs`; +- `src/model/table.rs`; +- model and renderer construction tests that use explicit struct literals. + +Deliverables: + +- zero-based inclusive `SpreadsheetCoordinate` and `SpreadsheetRange`; +- table-level `SpreadsheetSource` containing original sheet index, name, and + bounding range; +- optional `Table.source` and `Cell.source` fields; +- constructors/defaults preserve `None` for non-spreadsheet tables; +- no renderer behavior change. + +### Commit 2 — Excel provenance through the shared grid path + +Files: + +- `src/formats/sheet/xlsx.rs`; +- `src/formats/sheet/xlsb.rs`; +- `src/formats/sheet/xls.rs`; +- shared spreadsheet tests as needed. + +Deliverables: + +- carry source sheet identity before hidden-sheet filtering; +- attach original one-cell ranges to ordinary and generated empty cells; +- attach original merge rectangles to merged origins; +- compute table source ranges from original coordinates; +- preserve current normalized grid and Markdown output. + +### Commit 3 — ODS provenance with repeat-aware cursors + +Files: + +- `src/formats/odf/table.rs`; +- `src/formats/odf/mod.rs` only if sheet-order context must be threaded there; +- ODS parser tests. + +Deliverables: + +- source row/column cursors independent from normalized `GridBuilder` cursors; +- correct ranges for repeated rows, repeated columns, covered cells, and + row/column spans; +- source range for each returned ODS table; +- existing repeat and expansion limits unchanged. + +### Commit 4 — bindings, declarations, fixtures, and regression coverage + +Files: + +- `node/src/document.rs`, `node/index.d.ts`; +- `python/src/document.rs`, `python/anydoc/_anydoc.pyi`; +- `wasm/src/document.rs`, `wasm/src/typescript.rs`; +- binding tests and any generated declaration updates; +- README/API notes if the existing project convention requires them. + +Deliverables: + +- equivalent Rust/Node/Python/WASM shapes; +- optional provenance is absent/null according to each binding's existing + convention for non-spreadsheet documents; +- binding smoke tests for spreadsheet and DOCX/CSV cases; +- complete diff and compatibility review. + +## Fixture design + +The core parser tests already use compact in-module builders instead of +checking binary fixtures into the repository. Extend that pattern so each +format tests its real reader and the shared model contract. + +### XLSX fixture + +Build a workbook with: + +- visible sheet `Data Sheet` containing `C3 = "value"`; +- another visible sheet with a different name; +- a hidden sheet between them to verify original sheet indices; +- a visible sheet region starting at `D11:E12`; +- a merge such as `F1:G2` and a hidden row or column intersecting a merge. + +Assertions: + +- table source has the original sheet name/index and inclusive bounding range; +- the normalized first cell still has its original source coordinate; +- merged origin retains `F1:G2` even if normalized span changes; +- hidden sheets remain omitted and do not change source indices. + +### XLSB fixture + +Reuse the existing synthetic OPC workbook builder and binary record helpers. +Create the same logical workbook cases as XLSX, using `BrtBundleSh`, +`BrtRowHdr`, cell records, and `BrtMergeCell`. + +Assertions must compare the provenance shape to the XLSX expectations without +assuming the container's internal part names. + +### XLS fixture + +Reuse the existing OLE/BIFF builder and records. Create visible and hidden +`BOUNDSHEET` entries, BIFF cell records at non-zero row/column positions, and a +`MERGEDCELLS` rectangle. + +Assertions must verify BIFF row/column values are retained after the shared +grid builder crops the table. + +### ODS fixture + +Build a minimal `content.xml` with: + +- a table named `Data Sheet`; +- leading empty cells before a populated cell; +- `number-columns-repeated` for a repeated value; +- `number-rows-repeated` for repeated rows; +- a row/column span and explicit covered cells; +- a second table to verify table order and identity. + +Assertions must verify that repeat expansion receives distinct source +coordinates, spans retain their full source ranges, and normalized covered +slots still point to the correct origin. + +### Binding fixture policy + +Bindings should consume the core model fixtures or construct the smallest +in-memory documents possible. They must assert field names and value shapes, +not duplicate parser logic. + +## Verification commands + +Run, when the required toolchains are available: + +```text +cargo fmt --all -- --check +cargo test --workspace +npm test --prefix node +python -m pytest python/tests +``` + +If a binding requires a generated/native artifact or a toolchain is missing, +record the exact command and environment limitation rather than treating the +unrun test as passing. + +## Stop conditions + +Pause implementation and revisit the Spec if any of these occur: + +- source coordinates cannot be defined consistently across a format; +- preserving provenance requires changing Markdown output; +- hidden-sheet behavior would expose previously omitted content; +- a new dependency appears necessary; +- a binding cannot represent the core model without a format-specific shape; +- a public API compatibility issue requires a different model design. diff --git a/docs/spreadsheet-provenance-spec.md b/docs/spreadsheet-provenance-spec.md new file mode 100644 index 00000000..95a5d0b1 --- /dev/null +++ b/docs/spreadsheet-provenance-spec.md @@ -0,0 +1,236 @@ +# Spreadsheet provenance in the document model + +Status: proposed implementation spec + +Related issue: [Expose worksheet identity and source coordinates in `to_document()` #10](https://github.com/firecrawl/anydoc/issues/10) + +## Problem + +The spreadsheet readers already know the source worksheet and the original row +and column of each populated cell. During canonical grid construction, however, +hidden rows and columns are removed, merged regions are remapped, and the +resulting `Table` only exposes normalized grid coordinates. A caller therefore +cannot map `table.grid[0][0]` back to a worksheet cell such as `Data Sheet!C3`. + +The worksheet name is also currently represented only by a Markdown heading +when a workbook has more than one visible sheet. This makes worksheet identity +dependent on the rendering path and loses it for single-sheet workbooks. + +## Scope + +This change applies to the structured document model returned by +`to_document()`. It covers all spreadsheet containers currently parsed by +anydoc: + +- `.xlsx` and `.xlsm` (SpreadsheetML); +- `.xlsb` (binary SpreadsheetML); +- `.xls` (BIFF/OLE); +- `.ods` (OpenDocument Spreadsheet). + +CSV is intentionally out of scope: it has no worksheet identity or workbook +coordinate system. Markdown rendering, format detection, hidden-sheet policy, +and existing resource limits are otherwise unchanged. + +## Goals + +1. Preserve worksheet identity independently of Markdown rendering. +2. Give every returned spreadsheet origin cell an exact source range. +3. Keep normalized `Table.grid` behavior unchanged for existing consumers. +4. Expose the same provenance semantics through Rust, Node.js, Python, and + WASM bindings. +5. Preserve provenance through cropping, hidden row/column filtering, merged + cells, and ODS repeat expansion. + +## Non-goals + +- Preserve formulas, comments, styles, drawings, charts, or other spreadsheet + features that are not currently represented by the document model. +- Change the Markdown output to include sheet names or coordinates. +- Add file paths or file hashes to the model. `to_document()` accepts bytes and + does not have a reliable source path. +- Add provenance for non-spreadsheet tables in this change. + +## Coordinate contract + +Coordinates are zero-based and ranges are inclusive at both ends. + +```rust +SpreadsheetCoordinate { row: 2, column: 2 } // C3 +SpreadsheetRange { + start: SpreadsheetCoordinate { row: 2, column: 2 }, + end: SpreadsheetCoordinate { row: 2, column: 2 }, +} // C3:C3 +``` + +`sheet_index` is the zero-based position in the source workbook/table order, +including hidden Excel sheets. Hidden sheets remain omitted from the returned +document exactly as they are today; their presence must not renumber the +visible sheets that follow them. + +The table range is the smallest source-coordinate bounding range containing +all returned origin cells and the complete source ranges of merged origins. +It is not derived from the normalized grid dimensions. A range can contain +holes when hidden rows or columns were omitted. + +For a normal spreadsheet cell, `Cell.source` is a one-cell range. For a merged +origin, it is the complete source merge range, even if hidden rows or columns +cause the normalized span to be smaller. Covered grid slots continue to point +to their normalized origin; the origin cell carries the source range. + +Materialized empty padding cells also carry their original one-cell range. A +cell is allowed to have no source only when it was not produced by a +spreadsheet parser. + +## Proposed Rust model + +The following names are the proposed public API; exact naming can be adjusted +during upstream review without changing the semantics. + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SpreadsheetCoordinate { + pub row: u32, + pub column: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SpreadsheetRange { + pub start: SpreadsheetCoordinate, + pub end: SpreadsheetCoordinate, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SpreadsheetSource { + pub sheet_index: u32, + pub sheet_name: String, + pub range: SpreadsheetRange, +} + +pub struct Table { + pub grid: Vec>, + pub header_rows: usize, + pub kind: TableKind, + pub source: Option, +} + +pub struct Cell { + pub blocks: Vec, + pub col_span: u32, + pub row_span: u32, + pub source: Option, +} +``` + +`Table.source` is `Some` for tables produced by spreadsheet parsers and +`None` for DOC/DOCX, RTF, ODT, presentation, HTML-derived, and CSV tables. +The worksheet identity is stored once at table level; each cell stores only its +range to avoid duplicating the sheet name for every cell. + +## Parser changes + +### XLSX, XLSB, and XLS + +`xlsx`, `xlsb`, and `xls` already share `SheetContent` and `build_table()`. +The implementation should: + +1. carry a `SpreadsheetSource` sheet identity into `build_table()`; +2. retain the original `(row, column)` key while building each origin cell; +3. retain the original merge rectangle separately from its normalized span; +4. attach a one-cell range to generated empty cells; +5. compute the table bounding range from source coordinates before returning; +6. enumerate sheet identity before filtering hidden sheets. + +The normalized grid, merge behavior, number formatting, and hidden-content +policy must remain unchanged. + +### ODS + +`parse_spreadsheet()` and `parse_table()` need explicit logical source cursors +because ODS can encode a single cell or row with repeat attributes. + +- `number-rows-repeated` increments the source row for every emitted row; +- `number-columns-repeated` increments the source column for every emitted + cell; +- `number-columns-spanned` and `number-rows-spanned` produce an inclusive + source range covering the full origin span; +- `covered-table-cell` remains a covered slot when it belongs to an origin; +- a stray covered cell, if recovered as an empty origin, receives its source + coordinate; +- repeat expansion continues to use the existing safety budgets. + +## Binding contract + +All bindings expose the same data with their established naming conventions. + +| Binding | Public shape | +|---|---| +| Rust | `SpreadsheetCoordinate`, `SpreadsheetRange`, `SpreadsheetSource`; optional `Table.source` and `Cell.source` | +| Node.js | `sheetIndex`, `sheetName`, `range`, `start`, `end`, `row`, `column`; optional `source` | +| Python | `sheet_index`, `sheet_name`, `range`, `start`, `end`, `row`, `column`; optional `source` | +| WASM | camelCase JSON/TypeScript shape matching Node.js | + +The generated Node declaration, Python stub, and WASM TypeScript section must +be updated together with their conversion code. Non-spreadsheet output should +continue to expose `None`/`undefined`/omitted optional provenance according to +each binding's existing optional-field convention. + +## Test strategy + +### Core parser tests + +Add focused cases for each supported container: + +1. a single-sheet workbook with one value at `C3`; +2. multiple sheets with distinct names and a hidden sheet before a visible + sheet; +3. a non-`A1` used range such as `D11:E12`; +4. hidden rows and columns that change normalized grid positions; +5. merged cells whose source range differs from normalized span; +6. ODS repeated rows, repeated columns, and covered cells; +7. empty materialized padding cells; +8. non-spreadsheet and CSV tables with no provenance. + +The XLSX, XLSB, and XLS tests should exercise the shared grid path while still +asserting each format's reader. ODS tests should assert repeat and span cursor +behavior independently. + +### Regression tests + +- Existing Markdown snapshots and renderer tests remain unchanged. +- Existing table/grid invariants continue to pass. +- Binding tests assert the new fields and their absence for non-spreadsheet + tables. +- Existing malformed-input and resource-limit tests remain green. + +## Acceptance criteria + +The change is complete when: + +- `to_document()` returns `Data Sheet!C3:C3` and the cell range for a + single-value `C3` workbook; +- every returned spreadsheet origin cell is traceable to its original sheet + and inclusive source range; +- `.xlsx`, `.xlsm`, `.xlsb`, `.xls`, and `.ods` follow the same coordinate + contract; +- normalized grid shape and Markdown output do not regress; +- all four public surfaces expose the new metadata consistently; +- tests cover hidden coordinates, merges, repeats, and empty padding; +- no CSR project files are modified by this upstream change. + +## Compatibility and review risks + +`Table` and `Cell` currently expose public fields, so adding public fields can +break downstream code that constructs them with struct literals. This is an +intentional public-model change required by the issue and must be called out +in the PR description and release notes. The optional values preserve runtime +behavior for non-spreadsheet documents. + +## Implementation order + +1. Add and document the shared provenance types and optional model fields. +2. Thread sheet identity and source ranges through the shared Excel grid path. +3. Add ODS logical source cursors and range tracking. +4. Update Rust, Node.js, Python, and WASM bindings. +5. Add parser, binding, and regression tests. +6. Review the complete diff, run the relevant test matrix, and prepare the + upstream PR description. From b42a598ab33b1af1481c3b03e3939dcf52153905 Mon Sep 17 00:00:00 2001 From: 789987777777 <3606995509@qq.com> Date: Thu, 3 Sep 2026 20:55:07 +0800 Subject: [PATCH 2/9] feat(model): add spreadsheet source metadata --- src/formats/csv.rs | 2 + src/model/mod.rs | 2 + src/model/source.rs | 73 ++++++++++++++++++++++++++++++++++++ src/model/table.rs | 25 +++++++++--- src/render/markdown/tests.rs | 7 +++- 5 files changed, 103 insertions(+), 6 deletions(-) create mode 100644 src/model/source.rs diff --git a/src/formats/csv.rs b/src/formats/csv.rs index 5d4618b8..71891e55 100644 --- a/src/formats/csv.rs +++ b/src/formats/csv.rs @@ -118,7 +118,9 @@ mod tests { fn quoted_fields_keep_padding() { let doc = parse(b"a,b\n\" padded \",x\n").unwrap(); let Block::Table(t) = &doc.blocks[0] else { panic!() }; + assert!(t.source.is_none()); let crate::model::CellSlot::Origin(cell) = &t.grid[1][0] else { panic!() }; + assert!(cell.source.is_none()); let Block::Paragraph(inlines) = &cell.blocks[0] else { panic!() }; let crate::model::Inline::Text { text, .. } = &inlines[0] else { panic!() }; assert_eq!(text, " padded "); diff --git a/src/model/mod.rs b/src/model/mod.rs index e5e835a7..a628a58a 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -10,6 +10,7 @@ mod block; mod inline; mod link; mod list; +mod source; mod style; mod table; @@ -18,6 +19,7 @@ pub use block::Block; pub use inline::{Inline, checkbox_text, inlines_are_empty, inlines_to_plain_text}; pub use link::{AnchorId, ImageSource, LinkTarget}; pub use list::{List, ListItem, MarkerKind}; +pub use source::{SpreadsheetCoordinate, SpreadsheetRange, SpreadsheetSource}; pub use style::Style; pub use table::{Cell, CellSlot, Table, TableKind}; diff --git a/src/model/source.rs b/src/model/source.rs new file mode 100644 index 00000000..b263adf3 --- /dev/null +++ b/src/model/source.rs @@ -0,0 +1,73 @@ +//! Source coordinates retained by format frontends. + +/// A zero-based row and column in a spreadsheet worksheet. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SpreadsheetCoordinate { + /// Zero-based row. + pub row: u32, + /// Zero-based column. + pub column: u32, +} + +impl SpreadsheetCoordinate { + /// Construct a zero-based worksheet coordinate. + pub const fn new(row: u32, column: u32) -> Self { + SpreadsheetCoordinate { row, column } + } +} + +/// An inclusive source range in a spreadsheet worksheet. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SpreadsheetRange { + /// Inclusive range start. + pub start: SpreadsheetCoordinate, + /// Inclusive range end. + pub end: SpreadsheetCoordinate, +} + +impl SpreadsheetRange { + /// Construct a range containing one worksheet cell. + pub const fn cell(row: u32, column: u32) -> Self { + let coordinate = SpreadsheetCoordinate::new(row, column); + SpreadsheetRange { start: coordinate, end: coordinate } + } + + /// Construct an inclusive range from its four zero-based bounds. + pub const fn new(start_row: u32, start_column: u32, end_row: u32, end_column: u32) -> Self { + SpreadsheetRange { + start: SpreadsheetCoordinate::new(start_row, start_column), + end: SpreadsheetCoordinate::new(end_row, end_column), + } + } + + /// Expand this range to include another inclusive range. + pub fn include(&mut self, other: SpreadsheetRange) { + self.start.row = self.start.row.min(other.start.row); + self.start.column = self.start.column.min(other.start.column); + self.end.row = self.end.row.max(other.end.row); + self.end.column = self.end.column.max(other.end.column); + } +} + +/// The worksheet and source extent of a returned spreadsheet table. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SpreadsheetSource { + /// Zero-based position in the source workbook's worksheet order. + pub sheet_index: u32, + /// Worksheet name as stored by the source format. + pub sheet_name: String, + /// Inclusive source extent that produced the returned table. + pub range: SpreadsheetRange, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ranges_are_inclusive_and_can_form_a_bounding_box() { + let mut range = SpreadsheetRange::cell(2, 4); + range.include(SpreadsheetRange::new(0, 1, 5, 3)); + assert_eq!(range, SpreadsheetRange::new(0, 1, 5, 4)); + } +} diff --git a/src/model/table.rs b/src/model/table.rs index 94941580..53665cf7 100644 --- a/src/model/table.rs +++ b/src/model/table.rs @@ -1,5 +1,5 @@ use crate::error::ConvertError; -use crate::model::{Block, Inline, inlines_are_empty}; +use crate::model::{Block, Inline, SpreadsheetRange, SpreadsheetSource, inlines_are_empty}; use crate::package::limits; use std::collections::HashMap; @@ -16,6 +16,10 @@ pub struct Table { pub header_rows: usize, /// Whether the source used this table for data or for layout. pub kind: TableKind, + /// Worksheet identity and source extent for a spreadsheet table. + /// + /// This is `None` for tables from formats without worksheet coordinates. + pub source: Option, } /// What a table is for. @@ -53,12 +57,17 @@ pub struct Cell { pub col_span: u32, /// Rows covered, at least 1. pub row_span: u32, + /// Inclusive source range for a spreadsheet origin cell. + /// + /// A normal cell has a one-cell range; a merged origin has the complete + /// source merge range. This is `None` for cells from other formats. + pub source: Option, } impl Cell { /// A cell spanning one position. pub fn new(blocks: Vec) -> Self { - Cell { blocks, col_span: 1, row_span: 1 } + Cell { blocks, col_span: 1, row_span: 1, source: None } } /// A one-paragraph cell spanning one position. @@ -69,7 +78,7 @@ impl Cell { /// A cell covering `col_span` by `row_span` positions; either span given /// as 0 is raised to 1. pub fn spanning(blocks: Vec, col_span: u32, row_span: u32) -> Self { - Cell { blocks, col_span: col_span.max(1), row_span: row_span.max(1) } + Cell { blocks, col_span: col_span.max(1), row_span: row_span.max(1), source: None } } /// True when the cell holds nothing that would render: only paragraphs @@ -217,6 +226,12 @@ impl GridBuilder { /// `covered-table-cell`). Returns `false` when no span accounts for the /// position - the stray marker then becomes an empty cell. pub fn covered(&mut self) -> bool { + self.covered_with(Cell::default()) + } + + /// Consume one explicitly-written covered position, using `fallback` when + /// no span accounts for the position. + pub fn covered_with(&mut self, fallback: Cell) -> bool { let row = self.row_index(); let col = self.grid[row].len(); match self.pending.remove(&(row, col)) { @@ -225,7 +240,7 @@ impl GridBuilder { true } None => { - self.grid[row].push(CellSlot::Origin(Cell::default())); + self.grid[row].push(CellSlot::Origin(fallback)); false } } @@ -276,7 +291,7 @@ impl GridBuilder { } } } - Table { grid: self.grid, header_rows: 0, kind } + Table { grid: self.grid, header_rows: 0, kind, source: None } } } diff --git a/src/render/markdown/tests.rs b/src/render/markdown/tests.rs index 0a7b19a4..53c999e3 100644 --- a/src/render/markdown/tests.rs +++ b/src/render/markdown/tests.rs @@ -50,7 +50,12 @@ fn math_renders_in_dollar_delimiters_and_text_dollars_are_escaped() { #[test] fn math_in_a_table_cell_escapes_pipes() { - let cell = |inlines| Cell { blocks: vec![Block::Paragraph(inlines)], col_span: 1, row_span: 1 }; + let cell = |inlines| Cell { + blocks: vec![Block::Paragraph(inlines)], + col_span: 1, + row_span: 1, + source: None, + }; let md = doc(vec![table_from( vec![vec![cell(vec![Inline::plain("abs")]), cell(vec![Inline::Math("|x|".into())])]], 0, From 63950819f695561891fb5a67523b9fc2b59edd4e Mon Sep 17 00:00:00 2001 From: 789987777777 <3606995509@qq.com> Date: Thu, 3 Sep 2026 20:55:48 +0800 Subject: [PATCH 3/9] feat(spreadsheets): retain source coordinates --- src/formats/odf/mod.rs | 83 ++++++++++++++++++++++++++ src/formats/odf/table.rs | 115 +++++++++++++++++++++++++++++++----- src/formats/sheet/xls.rs | 75 ++++++++++++++++++++++-- src/formats/sheet/xlsb.rs | 92 +++++++++++++++++++++++++---- src/formats/sheet/xlsx.rs | 120 +++++++++++++++++++++++++++++++++----- 5 files changed, 442 insertions(+), 43 deletions(-) diff --git a/src/formats/odf/mod.rs b/src/formats/odf/mod.rs index e4a64553..b23a606f 100644 --- a/src/formats/odf/mod.rs +++ b/src/formats/odf/mod.rs @@ -177,6 +177,7 @@ fn push_title_heading(inner: Vec, blocks: &mut Vec) { #[cfg(test)] mod tests { use super::*; + use crate::model::{CellSlot, SpreadsheetRange, Table}; use std::io::{Cursor, Write}; const CONTENT: &[u8] = br#" String { + format!( + r#" + {tables} + "# + ) + } + #[test] fn repeated_rows_cannot_amplify_text_beyond_the_byte_budget() { // H3: the slot budget alone would admit 1000 copies of a 100 KB @@ -281,6 +293,77 @@ mod tests { panic!("unexpected cell blocks: {:?}", cell.blocks) }; assert_eq!(inside, "inside cell"); + assert!(table.source.is_none(), "ODT tables do not have worksheet provenance"); + } + + #[test] + fn spreadsheet_sources_follow_repeat_and_span_cursors() { + let content = spreadsheet_doc( + r#" + + + + + + + + + + + + + + + + + + + "#, + ); + let doc = parse(&odt_with_content(&content)).unwrap(); + let tables: Vec<&Table> = doc + .blocks + .iter() + .filter_map(|block| match block { + Block::Table(table) => Some(table), + _ => None, + }) + .collect(); + assert_eq!(tables.len(), 2); + + let first_source = tables[0].source.as_ref().expect("spreadsheet source"); + assert_eq!(first_source.sheet_index, 0); + assert_eq!(first_source.sheet_name, "Data Sheet"); + assert_eq!(first_source.range, SpreadsheetRange::new(0, 0, 2, 4)); + let CellSlot::Origin(value) = &tables[0].grid[0][2] else { + panic!("expected the value origin at C1"); + }; + assert_eq!(value.source, Some(SpreadsheetRange::cell(0, 2))); + let CellSlot::Origin(repeated_one) = &tables[0].grid[1][0] else { + panic!("expected the first repeated row"); + }; + let CellSlot::Origin(repeated_two) = &tables[0].grid[2][0] else { + panic!("expected the second repeated row"); + }; + assert_eq!(repeated_one.source, Some(SpreadsheetRange::cell(1, 0))); + assert_eq!(repeated_two.source, Some(SpreadsheetRange::cell(2, 0))); + + let second_source = tables[1].source.as_ref().expect("spreadsheet source"); + assert_eq!(second_source.sheet_index, 1); + assert_eq!(second_source.sheet_name, "Merged"); + assert_eq!(second_source.range, SpreadsheetRange::new(0, 0, 1, 2)); + let CellSlot::Origin(span) = &tables[1].grid[0][0] else { + panic!("expected the merged origin"); + }; + assert_eq!((span.col_span, span.row_span), (2, 2)); + assert_eq!(span.source, Some(SpreadsheetRange::new(0, 0, 1, 1))); + assert!(matches!(tables[1].grid[0][1], CellSlot::Covered { .. })); + assert!(matches!(tables[1].grid[1][0], CellSlot::Covered { .. })); + assert!(matches!(tables[1].grid[1][1], CellSlot::Covered { .. })); + let CellSlot::Origin(tail) = &tables[1].grid[1][2] else { + panic!("expected the tail origin"); + }; + assert_eq!(tail.source, Some(SpreadsheetRange::cell(1, 2))); } #[test] diff --git a/src/formats/odf/table.rs b/src/formats/odf/table.rs index 226446b2..791146c8 100644 --- a/src/formats/odf/table.rs +++ b/src/formats/odf/table.rs @@ -10,7 +10,10 @@ use crate::error::ConvertError; use crate::formats::odf::text::{Ctx, parse_container}; -use crate::model::{Block, Cell, GridBuilder, Inline, TableKind}; +use crate::model::{ + Block, Cell, CellSlot, GridBuilder, Inline, SpreadsheetRange, SpreadsheetSource, Table, + TableKind, +}; use crate::package::limits; use crate::package::xml::{Element, ns}; use crate::shared::header::resolve_header_rows; @@ -18,6 +21,14 @@ use crate::shared::text::clean_text; use std::collections::HashMap; pub fn parse_table(elem: &Element, ctx: &Ctx) -> Result, ConvertError> { + parse_table_with_source(elem, ctx, None) +} + +fn parse_table_with_source( + elem: &Element, + ctx: &Ctx, + source: Option<(u32, &str)>, +) -> Result, ConvertError> { let mut state = TableState { builder: GridBuilder::new(), expansion: 0, @@ -25,6 +36,7 @@ pub fn parse_table(elem: &Element, ctx: &Ctx) -> Result, ConvertError pending_rows: 0, header_rows: 0, rows_emitted: 0, + next_source_row: 0, checkboxes: read_checkboxes(elem), }; walk_rows(elem, ctx, &mut state, true)?; @@ -33,6 +45,12 @@ pub fn parse_table(elem: &Element, ctx: &Ctx) -> Result, ConvertError return Ok(Vec::new()); } table.header_rows = resolve_header_rows(&table, state.header_rows); + if let Some((sheet_index, sheet_name)) = source + && let Some(range) = table_source_range(&table) + { + table.source = + Some(SpreadsheetSource { sheet_index, sheet_name: sheet_name.to_string(), range }); + } Ok(vec![Block::Table(table)]) } @@ -45,6 +63,8 @@ struct TableState { pending_rows: u64, header_rows: usize, rows_emitted: usize, + /// Source row of the next row element, including rows buffered as empty. + next_source_row: u64, /// The sheet's form checkboxes by control id, as the inlines a /// `draw:control` in a cell expands to. checkboxes: HashMap>, @@ -217,6 +237,8 @@ fn emit_row( state: &mut TableState, repeat: u64, ) -> Result<(), ConvertError> { + let source_row = state.next_source_row; + state.next_source_row = state.next_source_row.saturating_add(repeat); if row_is_empty(row) { state.pending_rows = state.pending_rows.saturating_add(repeat); return Ok(()); @@ -243,10 +265,11 @@ fn emit_row( state.charge_bytes(bytes.saturating_mul(copies))?; } } - for _ in 0..repeat { + for row_offset in 0..repeat { + let emitted_source_row = source_row.saturating_add(row_offset); state.builder.next_row(); state.rows_emitted += 1; - emit_parsed_cells(&cells, state)?; + emit_parsed_cells(&cells, state, emitted_source_row)?; } Ok(()) } @@ -289,31 +312,48 @@ fn parse_row_cells( Ok(out) } -fn emit_parsed_cells(cells: &[RowCell], state: &mut TableState) -> Result<(), ConvertError> { +fn emit_parsed_cells( + cells: &[RowCell], + state: &mut TableState, + source_row: u64, +) -> Result<(), ConvertError> { let mut pending_cells: u64 = 0; + let mut source_col: u64 = 0; for cell in cells { match cell { RowCell::Covered { repeat } => { - flush_gap(state, &mut pending_cells)?; + let gap_start = source_col.saturating_sub(pending_cells); + flush_gap(state, &mut pending_cells, source_row, gap_start)?; // One explicitly written covered position each; a stray one // (no span accounts for it) becomes an empty cell inside // covered(). state.charge(*repeat)?; for _ in 0..*repeat { - if !state.builder.covered() { + let source = source_cell(source_row, source_col)?; + if !state.builder.covered_with(Cell { source: Some(source), ..Cell::default() }) + { log::debug!("covered table cell without a spanning origin"); } + source_col = source_col.saturating_add(1); } } RowCell::Cell { repeat, col_span, row_span, blocks, .. } => { if blocks.is_empty() && *col_span == 1 && *row_span == 1 { pending_cells = pending_cells.saturating_add(*repeat); + source_col = source_col.saturating_add(*repeat); continue; } - flush_gap(state, &mut pending_cells)?; + let gap_start = source_col.saturating_sub(pending_cells); + flush_gap(state, &mut pending_cells, source_row, gap_start)?; state.charge(repeat.saturating_mul(*col_span as u64))?; for _ in 0..*repeat { - state.builder.place(Cell::spanning(blocks.clone(), *col_span, *row_span))?; + let end_row = source_row.saturating_add(u64::from(*row_span).saturating_sub(1)); + let end_col = source_col.saturating_add(u64::from(*col_span).saturating_sub(1)); + let source = source_range(source_row, source_col, end_row, end_col)?; + let mut origin = Cell::spanning(blocks.clone(), *col_span, *row_span); + origin.source = Some(source); + state.builder.place(origin)?; + source_col = source_col.saturating_add(u64::from(*col_span)); } } } @@ -323,18 +363,67 @@ fn emit_parsed_cells(cells: &[RowCell], state: &mut TableState) -> Result<(), Co /// Materialize a buffered empty-cell run in full so the next cell lands on /// its source column. Trailing runs are never flushed and stay elided. -fn flush_gap(state: &mut TableState, pending: &mut u64) -> Result<(), ConvertError> { +fn flush_gap( + state: &mut TableState, + pending: &mut u64, + source_row: u64, + source_col: u64, +) -> Result<(), ConvertError> { if *pending == 0 { return Ok(()); } state.charge(*pending)?; - for _ in 0..*pending { - state.builder.place(Cell::default())?; + for offset in 0..*pending { + let source = source_cell(source_row, source_col.saturating_add(offset))?; + state.builder.place(Cell { source: Some(source), ..Cell::default() })?; } *pending = 0; Ok(()) } +fn source_cell(row: u64, column: u64) -> Result { + source_range(row, column, row, column) +} + +fn source_range( + start_row: u64, + start_column: u64, + end_row: u64, + end_column: u64, +) -> Result { + let to_u32 = |value: u64, axis: &str| { + u32::try_from(value).map_err(|_| ConvertError::ResourceLimit { + limit: "spreadsheet_coordinates", + detail: format!("{axis} coordinate {value} exceeds the model range"), + }) + }; + Ok(SpreadsheetRange::new( + to_u32(start_row, "row")?, + to_u32(start_column, "column")?, + to_u32(end_row, "row")?, + to_u32(end_column, "column")?, + )) +} + +fn table_source_range(table: &Table) -> Option { + let mut range: Option = None; + for row in &table.grid { + for slot in row { + let CellSlot::Origin(cell) = slot else { + continue; + }; + let Some(source) = cell.source else { + continue; + }; + match &mut range { + Some(found) => found.include(source), + None => range = Some(source), + } + } + } + range +} + /// A cell's blocks: its text content, or a typed value-attribute fallback /// when the producer wrote no display text. fn cell_blocks(cell: &Element, ctx: &Ctx) -> Result, ConvertError> { @@ -463,9 +552,9 @@ pub fn parse_spreadsheet(sheet: &Element, ctx: &Ctx) -> Result, Conve let tables: Vec<&Element> = sheet.child_elems().filter(|e| e.is(ns::TABLE, "table")).collect(); let multi_sheet = tables.len() > 1; let mut blocks = Vec::new(); - for table in tables { + for (sheet_index, table) in tables.into_iter().enumerate() { let name = table.attr(ns::TABLE, "name").unwrap_or(""); - let content = parse_table(table, ctx)?; + let content = parse_table_with_source(table, ctx, Some((sheet_index as u32, name)))?; if content.is_empty() { continue; } diff --git a/src/formats/sheet/xls.rs b/src/formats/sheet/xls.rs index 28488fdc..0cd13919 100644 --- a/src/formats/sheet/xls.rs +++ b/src/formats/sheet/xls.rs @@ -59,19 +59,25 @@ pub(super) fn parse(bytes: &[u8]) -> Result { let mut records = 0u64; let globals = read_globals(&data, &mut records)?; - let visible: Vec<&BoundSheet> = globals.sheets.iter().filter(|s| s.visible).collect(); + let visible: Vec<(u32, &BoundSheet)> = globals + .sheets + .iter() + .enumerate() + .filter(|(_, s)| s.visible) + .map(|(index, sheet)| (index as u32, sheet)) + .collect(); let multi_sheet = visible.len() > 1; let mut doc = Document::default(); let mut failed = 0usize; // One budget for the workbook, so sheets cannot multiply the cap. let mut slots = 0u64; - for sheet in &visible { + for (sheet_index, sheet) in &visible { let Some(content) = read_sheet(&data, &globals, sheet.offset, &mut records)? else { log::warn!("skipping unreadable sheet {:?}", sheet.name); failed += 1; continue; }; - let Some(table) = build_table(content, &mut slots)? else { + let Some(table) = build_table(content, *sheet_index, &sheet.name, &mut slots)? else { continue; }; if multi_sheet { @@ -840,7 +846,7 @@ fn string_reader<'a>(segs: &'a [&'a [u8]], skip: usize) -> Option> #[cfg(test)] mod tests { use super::*; - use crate::model::{CellSlot, Table, inlines_to_plain_text}; + use crate::model::{CellSlot, SpreadsheetRange, Table, inlines_to_plain_text}; use std::io::Write; fn rec(rec_type: u16, body: &[u8]) -> Vec { @@ -1140,7 +1146,65 @@ mod tests { }; let doc = parse(&wb.build()).unwrap(); assert_eq!(doc.blocks.len(), 1, "hidden sheet must add no heading and no table"); - assert_eq!(texts(first_table(&doc)), vec![vec!["a", "c"], vec!["d", ""]]); + let table = first_table(&doc); + assert_eq!(texts(table), vec![vec!["a", "c"], vec!["d", ""]]); + let CellSlot::Origin(cell) = &table.grid[0][0] else { panic!() }; + assert_eq!(cell.source, Some(SpreadsheetRange::cell(0, 0))); + let CellSlot::Origin(cell) = &table.grid[0][1] else { panic!() }; + assert_eq!(cell.source, Some(SpreadsheetRange::cell(0, 2))); + let CellSlot::Origin(cell) = &table.grid[1][0] else { panic!() }; + assert_eq!(cell.source, Some(SpreadsheetRange::cell(2, 0))); + let CellSlot::Origin(cell) = &table.grid[1][1] else { panic!() }; + assert_eq!(cell.source, Some(SpreadsheetRange::cell(2, 2))); + } + + #[test] + fn source_coordinates_keep_workbook_order_and_sparse_extents() { + let first = label(2, 2, 0, "value"); + let hidden = label(0, 0, 0, "hidden"); + let mut third = label(10, 3, 0, "d"); + third.extend(label(11, 4, 0, "e")); + let wb = Wb { + xfs: vec![0], + sheets: vec![("Data Sheet", 0, first), ("Hidden", 1, hidden), ("Report", 0, third)], + ..Wb::default() + }; + let doc = parse(&wb.build()).unwrap(); + let tables: Vec<&Table> = doc + .blocks + .iter() + .filter_map(|block| match block { + Block::Table(table) => Some(table), + _ => None, + }) + .collect(); + assert_eq!(tables.len(), 2); + + let first_source = tables[0].source.as_ref().expect("spreadsheet source"); + assert_eq!(first_source.sheet_index, 0); + assert_eq!(first_source.sheet_name, "Data Sheet"); + assert_eq!(first_source.range, SpreadsheetRange::cell(2, 2)); + let CellSlot::Origin(cell) = &tables[0].grid[0][0] else { + panic!("expected C3 origin"); + }; + assert_eq!(cell.source, Some(SpreadsheetRange::cell(2, 2))); + + let third_source = tables[1].source.as_ref().expect("spreadsheet source"); + assert_eq!(third_source.sheet_index, 2, "hidden sheet must not renumber provenance"); + assert_eq!(third_source.sheet_name, "Report"); + assert_eq!(third_source.range, SpreadsheetRange::new(10, 3, 11, 4)); + let CellSlot::Origin(cell) = &tables[1].grid[0][0] else { + panic!("expected D11 origin"); + }; + assert_eq!(cell.source, Some(SpreadsheetRange::cell(10, 3))); + let CellSlot::Origin(cell) = &tables[1].grid[0][1] else { + panic!("expected generated E11 padding"); + }; + assert_eq!(cell.source, Some(SpreadsheetRange::cell(10, 4))); + let CellSlot::Origin(cell) = &tables[1].grid[1][1] else { + panic!("expected E12 origin"); + }; + assert_eq!(cell.source, Some(SpreadsheetRange::cell(11, 4))); } #[test] @@ -1161,6 +1225,7 @@ mod tests { panic!("expected the merge origin at (0,0)"); }; assert_eq!((cell.col_span, cell.row_span), (10, 3)); + assert_eq!(cell.source, Some(SpreadsheetRange::new(0, 5, 2, 14))); } #[test] diff --git a/src/formats/sheet/xlsb.rs b/src/formats/sheet/xlsb.rs index 7bc042f0..4b67c839 100644 --- a/src/formats/sheet/xlsb.rs +++ b/src/formats/sheet/xlsb.rs @@ -59,15 +59,17 @@ pub(super) fn parse(pkg: &mut Package, wb_part: &str) -> Result = Vec::new(); - for (name, rid) in bundles { + // in xlsx: by relationship id, never by conventional part name. Each + // tuple retains the original workbook-order index before hidden sheets + // are omitted. + let mut sheets: Vec<(u32, String, String)> = Vec::new(); + for (sheet_index, name, rid) in bundles { let Some(target) = wb_rels.internal_target(&rid) else { log::warn!("skipping sheet {name:?} with no worksheet relationship"); continue; }; match path::resolve(wb_part, target) { - Ok(t) => sheets.push((name, t.path)), + Ok(t) => sheets.push((sheet_index, name, t.path)), Err(e) => log::warn!("skipping sheet {name:?} with unresolvable target: {e}"), } } @@ -77,7 +79,7 @@ pub(super) fn parse(pkg: &mut Package, wb_part: &str) -> Result Result( } /// `xl/workbook.bin`: the 1904 date flag from BrtWbProp, and each visible -/// sheet's name and relationship id from its BrtBundleSh. -fn read_workbook(data: &[u8]) -> Result<(bool, Vec<(String, String)>), ConvertError> { +/// sheet's source-order index, name, and relationship id from its BrtBundleSh. +fn read_workbook(data: &[u8]) -> Result<(bool, Vec<(u32, String, String)>), ConvertError> { let mut date1904 = false; let mut sheets = Vec::new(); + let mut sheet_index = 0u32; let mut records = Records::new(data); while let Some((id, payload)) = records.next()? { match id { @@ -145,13 +148,15 @@ fn read_workbook(data: &[u8]) -> Result<(bool, Vec<(String, String)>), ConvertEr f.u32()?; // iTabID let rid = f.nullable_wide_string()?; let name = clean_text(&f.wide_string()?); + let current_index = sheet_index; + sheet_index = sheet_index.saturating_add(1); // hsState 1 is hidden, 2 is veryHidden: omitted entirely, // heading included, exactly as in xlsx. if state == 1 || state == 2 { continue; } match rid { - Some(rid) => sheets.push((name, rid)), + Some(rid) => sheets.push((current_index, name, rid)), None => log::warn!("skipping sheet {name:?} with no worksheet relationship"), } } @@ -460,7 +465,7 @@ impl<'a> Fields<'a> { #[cfg(test)] mod tests { use super::*; - use crate::model::{CellSlot, Table, inlines_to_plain_text}; + use crate::model::{CellSlot, SpreadsheetRange, Table, inlines_to_plain_text}; use std::io::Write; const PKG_RELS: &str = "http://schemas.openxmlformats.org/package/2006/relationships"; @@ -822,7 +827,71 @@ mod tests { }; let doc = parse(&wb.build()).unwrap(); assert_eq!(doc.blocks.len(), 1, "hidden sheets must add no heading and no table"); - assert_eq!(texts(first_table(&doc)), vec![vec!["1", "3"], vec!["5", ""]]); + let table = first_table(&doc); + assert_eq!(texts(table), vec![vec!["1", "3"], vec!["5", ""]]); + let CellSlot::Origin(cell) = &table.grid[0][0] else { panic!() }; + assert_eq!(cell.source, Some(SpreadsheetRange::cell(0, 0))); + let CellSlot::Origin(cell) = &table.grid[0][1] else { panic!() }; + assert_eq!(cell.source, Some(SpreadsheetRange::cell(0, 2))); + let CellSlot::Origin(cell) = &table.grid[1][0] else { panic!() }; + assert_eq!(cell.source, Some(SpreadsheetRange::cell(2, 0))); + let CellSlot::Origin(cell) = &table.grid[1][1] else { panic!() }; + assert_eq!(cell.source, Some(SpreadsheetRange::cell(2, 2))); + } + + #[test] + fn source_coordinates_keep_workbook_order_and_sparse_extents() { + let mut first = row_hdr(2, false); + first.extend(real_cell(2, 0, 7.0)); + let hidden = { + let mut body = row_hdr(0, false); + body.extend(real_cell(0, 0, 8.0)); + body + }; + let mut third = row_hdr(10, false); + third.extend(real_cell(3, 0, 1.0)); + third.extend(row_hdr(11, false)); + third.extend(real_cell(4, 0, 2.0)); + let wb = Wb { + sheets: vec![("Data Sheet", 0, first), ("Hidden", 1, hidden), ("Report", 0, third)], + ..Wb::default() + }; + let doc = parse(&wb.build()).unwrap(); + let tables: Vec<&Table> = doc + .blocks + .iter() + .filter_map(|block| match block { + Block::Table(table) => Some(table), + _ => None, + }) + .collect(); + assert_eq!(tables.len(), 2); + + let first_source = tables[0].source.as_ref().expect("spreadsheet source"); + assert_eq!(first_source.sheet_index, 0); + assert_eq!(first_source.sheet_name, "Data Sheet"); + assert_eq!(first_source.range, SpreadsheetRange::cell(2, 2)); + let CellSlot::Origin(cell) = &tables[0].grid[0][0] else { + panic!("expected C3 origin"); + }; + assert_eq!(cell.source, Some(SpreadsheetRange::cell(2, 2))); + + let third_source = tables[1].source.as_ref().expect("spreadsheet source"); + assert_eq!(third_source.sheet_index, 2, "hidden sheet must not renumber provenance"); + assert_eq!(third_source.sheet_name, "Report"); + assert_eq!(third_source.range, SpreadsheetRange::new(10, 3, 11, 4)); + let CellSlot::Origin(cell) = &tables[1].grid[0][0] else { + panic!("expected D11 origin"); + }; + assert_eq!(cell.source, Some(SpreadsheetRange::cell(10, 3))); + let CellSlot::Origin(cell) = &tables[1].grid[0][1] else { + panic!("expected generated E11 padding"); + }; + assert_eq!(cell.source, Some(SpreadsheetRange::cell(10, 4))); + let CellSlot::Origin(cell) = &tables[1].grid[1][1] else { + panic!("expected E12 origin"); + }; + assert_eq!(cell.source, Some(SpreadsheetRange::cell(11, 4))); } #[test] @@ -843,6 +912,7 @@ mod tests { panic!("expected the merge origin at (0,0)"); }; assert_eq!((cell.col_span, cell.row_span), (10, 3)); + assert_eq!(cell.source, Some(SpreadsheetRange::new(0, 5, 2, 14))); } #[test] diff --git a/src/formats/sheet/xlsx.rs b/src/formats/sheet/xlsx.rs index e0924562..5a85f7d4 100644 --- a/src/formats/sheet/xlsx.rs +++ b/src/formats/sheet/xlsx.rs @@ -7,7 +7,10 @@ use super::controls::{Checkboxes, cell_inlines, read_vml_checkboxes}; use super::numfmt::{DateParts, NumberFormat, Rendered, builtin_code}; use super::{format_duration_days, format_float, format_time_of_day}; use crate::error::ConvertError; -use crate::model::{Block, Cell, Document, GridBuilder, Inline, Table, TableKind}; +use crate::model::{ + Block, Cell, Document, GridBuilder, Inline, SpreadsheetRange, SpreadsheetSource, Table, + TableKind, +}; use crate::package::limits; use crate::package::relationships::{Relationships, read_rels, rel_type, rels_part_for}; use crate::package::xml::{Element, ns}; @@ -48,11 +51,12 @@ pub(super) fn parse(pkg: &mut Package, wb_part: &str) -> Result