From 4e823a45a350dc47881aaa3e56adb31f1d966c7f Mon Sep 17 00:00:00 2001 From: Toshiki Iga Date: Fri, 24 Apr 2026 09:10:41 +0900 Subject: [PATCH 1/5] =?UTF-8?q?miku-docx2md=20=E3=81=AE=E5=88=9D=E6=9C=9F?= =?UTF-8?q?=E6=A7=8B=E6=88=90=E3=81=A8=E4=BB=95=E6=A7=98=E3=83=BB=E5=AE=9F?= =?UTF-8?q?=E8=A3=85=E9=AA=A8=E6=A0=BC=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 codex GPT-5.4と簡単に対話して出来上がったもの。 - `miku-docx2md` の初期構成を追加する変更です。 - 仕様文書、実装仕様の雛形、TODO、プロジェクト設定、`src/ts` / `src/js` の骨格、node runtime 用スクリプト、テストを追加しています。 ## 変更内容 - ドキュメントを追加 - `README.md` - `TODO.md` - `docs/docx2md-spec.md` - `docs/docx2md-impl-spec.md` - `docs/upstream.md` - プロジェクト設定を追加 - `.gitignore` - `package.json` - `tsconfig.json` - `workplace/.gitkeep` - runtime / module 読み込みまわりを追加 - `scripts/lib/docx2md-module-order.mjs` - `scripts/lib/docx2md-node-runtime.mjs` - `src/ts` と `src/js` に実装骨格を追加 - `core` - `document-parser` - `module-registry` - `rels-parser` - `xml-utils` - `zip-io` - テストを追加 - `tests/docx2md-node-runtime.test.js` ## 補足 - このコミットでは、24 ファイル追加、1908 行追加が確認できます。 --- .gitignore | 3 + README.md | 120 ++++++ TODO.md | 46 +++ docs/docx2md-impl-spec.md | 187 +++++++++ docs/docx2md-spec.md | 548 +++++++++++++++++++++++++++ docs/upstream.md | 27 ++ package.json | 16 + scripts/lib/docx2md-module-order.mjs | 8 + scripts/lib/docx2md-node-runtime.mjs | 63 +++ src/js/core.js | 29 ++ src/js/document-parser.js | 63 +++ src/js/module-registry.js | 24 ++ src/js/rels-parser.js | 49 +++ src/js/xml-utils.js | 60 +++ src/js/zip-io.js | 110 ++++++ src/ts/core.ts | 38 ++ src/ts/document-parser.ts | 84 ++++ src/ts/module-registry.ts | 33 ++ src/ts/rels-parser.ts | 52 +++ src/ts/xml-utils.ts | 62 +++ src/ts/zip-io.ts | 119 ++++++ tests/docx2md-node-runtime.test.js | 153 ++++++++ tsconfig.json | 13 + workplace/.gitkeep | 1 + 24 files changed, 1908 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 TODO.md create mode 100644 docs/docx2md-impl-spec.md create mode 100644 docs/docx2md-spec.md create mode 100644 docs/upstream.md create mode 100644 package.json create mode 100644 scripts/lib/docx2md-module-order.mjs create mode 100644 scripts/lib/docx2md-node-runtime.mjs create mode 100644 src/js/core.js create mode 100644 src/js/document-parser.js create mode 100644 src/js/module-registry.js create mode 100644 src/js/rels-parser.js create mode 100644 src/js/xml-utils.js create mode 100644 src/js/zip-io.js create mode 100644 src/ts/core.ts create mode 100644 src/ts/document-parser.ts create mode 100644 src/ts/module-registry.ts create mode 100644 src/ts/rels-parser.ts create mode 100644 src/ts/xml-utils.ts create mode 100644 src/ts/zip-io.ts create mode 100644 tests/docx2md-node-runtime.test.js create mode 100644 tsconfig.json create mode 100644 workplace/.gitkeep diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..39d4f9a --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +workplace/* +!workplace/.gitkeep diff --git a/README.md b/README.md new file mode 100644 index 0000000..d80cf51 --- /dev/null +++ b/README.md @@ -0,0 +1,120 @@ +# miku-docx2md + +`miku-docx2md` is one of the tools in Mikuku's software series. + +## What is this? + +`miku-docx2md` is a tool that reads Word documents in `.docx` format and converts their textual structure into Markdown. + +- runs locally +- aims at meaningful Markdown extraction rather than visual reproduction +- is designed for both human readability and generative AI input + +This project follows the sibling-app direction of `miku-xlsx2md` where practical, while adapting the parsing model to Word documents. + +## Features Direction + +The first cut focuses on text-oriented Word content that maps naturally into GitHub-compatible Markdown / HTML. + +- paragraphs +- headings +- inline formatting such as `bold`, `italic`, `strike`, and `underline` +- paragraph-internal line breaks as `
` +- external hyperlinks +- document-internal hyperlinks when resolvable +- bullet lists +- numbered lists +- nested lists +- tables + +## Out of Scope in First Cut + +The first cut intentionally excludes visual and layout-heavy Word features. + +- images +- shapes +- SmartArt +- WordArt +- text boxes +- floating objects +- charts +- drawing objects +- exact page layout reproduction +- headers / footers +- footnotes / endnotes +- comments +- tracked changes +- macros + +## Conversion Policy + +`miku-docx2md` prioritizes extracting document structure into meaningful Markdown rather than reproducing Word appearance exactly. + +- preserve features that fit naturally into GitHub-compatible Markdown / HTML +- do not force awkward pseudo-reproduction for features that do not fit that output model +- preserve document order +- prefer stable and deterministic output + +## Current Design Decisions + +- `.docx` is handled as a ZIP package +- ZIP expansion is intended to be implemented in-house, following the sibling-app style +- source-of-truth implementation is intended to live under `src/ts/` +- `main.ts` is intended as the browser UI entry point +- `core.ts` is intended as the conversion orchestration layer + +Current first-cut document handling direction includes: + +- heading detection by both paragraph style and outline level +- nested list support via `numbering.xml` +- table cell line breaks rendered as `
` +- merged table cells simplified with `←M←` and `↑M↑` +- tabs normalized to four spaces +- consecutive meaningless empty paragraphs compressed +- unsupported elements omitted by default, with optional HTML comment traces in a debug-oriented mode + +## Feature Support Overview + +| Item | `miku-docx2md` status | Notes | +| --- | --- | --- | +| Read `.docx` files | Planned | Primary first-cut input | +| Convert document text into Markdown | Planned | Main project goal | +| Preserve headings | Planned | Style + outline level based | +| Preserve inline formatting | Planned | GitHub-compatible Markdown / HTML subset | +| Preserve external hyperlinks | Planned | Markdown links | +| Preserve document-internal hyperlinks | Planned | When anchor resolution is possible | +| Preserve nested lists | Planned | Based on numbering structure | +| Extract tables | Planned | Structural Markdown tables | +| Preserve merged table layout exactly | Not supported | Uses `←M←` / `↑M↑` placeholders instead | +| Extract images and shapes | Not supported in first cut | Explicitly out of scope | +| Reproduce Word appearance exactly | Not supported | Structure over visual fidelity | + +## How it works + +The intended first-cut flow is: + +1. Read a `.docx` file locally +2. Expand the ZIP package +3. Read `document.xml`, rels, styles, and numbering data +4. Build an internal document model +5. Convert supported structure into Markdown + +## Specifications + +For more details, see: + +- High-level specification and design policy: [docs/docx2md-spec.md](./docs/docx2md-spec.md) +- Implementation-oriented specification skeleton: [docs/docx2md-impl-spec.md](./docs/docx2md-impl-spec.md) +- Upstream reference policy: [docs/upstream.md](./docs/upstream.md) + +## Tech Direction + +- Runtime: local processing, browser-capable +- Source language: TypeScript +- Build direction: single-file web app style where practical +- Test direction: fixture-based tests + +## License + +- Released under the Apache License 2.0 +- See [LICENSE](./LICENSE) for the full license text diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..65a6cd8 --- /dev/null +++ b/TODO.md @@ -0,0 +1,46 @@ +# TODO + +## Phase 1: Project Skeleton + +- [x] Create `README.md` +- [x] Create `docs/docx2md-spec.md` +- [x] Create `docs/docx2md-impl-spec.md` +- [x] Add project package metadata and test scripts +- [x] Add `src/ts/` and `src/js/` skeleton +- [x] Add minimal node runtime loader + +## Phase 2: Core Parsing Foundation + +- [x] Implement ZIP expansion in-house +- [x] Implement XML utility helpers +- [x] Implement relationship path resolution +- [x] Implement minimal `.docx` document parser +- [x] Implement minimal Markdown renderer + +## Phase 3: First-Cut Features + +- [x] Paragraph extraction +- [ ] Heading detection +- [ ] Inline formatting +- [x] External hyperlinks +- [x] Internal hyperlinks +- [ ] Lists with nesting +- [ ] Tables +- [ ] Merge placeholders `←M←` / `↑M↑` + +## Phase 4: Diagnostics + +- [ ] Summary counts +- [ ] Unsupported element diagnostics +- [ ] Debug option for unsupported HTML comments + +## Phase 5: Tests + +- [x] Plain paragraph fixture +- [ ] Heading fixture +- [ ] Inline formatting fixture +- [x] External hyperlink fixture +- [x] Internal hyperlink fixture +- [ ] Nested list fixture +- [ ] Table fixture +- [ ] Unsupported element debug fixture diff --git a/docs/docx2md-impl-spec.md b/docs/docx2md-impl-spec.md new file mode 100644 index 0000000..4f50336 --- /dev/null +++ b/docs/docx2md-impl-spec.md @@ -0,0 +1,187 @@ +# docx2md Implementation Specification + +## 1. Document Role + +This document is the implementation-oriented companion to `docx2md-spec.md`. + +Its purpose is to record how the actual `docx2md` implementation behaves, once the first cut begins to take shape. + +This document should eventually describe: + +- implementation-aligned behavior based on the TypeScript source under `src/ts/` +- actual processing order and fallback behavior +- internal models and conversion boundaries +- places where implementation reality differs from idealized design intention + +At the current stage, this file serves as the implementation-spec skeleton. + +## 2. Relationship to Other Documents + +The intended role split is: + +- [README.md](../README.md) + - entry document for users and readers +- [docx2md-spec.md](./docx2md-spec.md) + - high-level specification, design policy, and first-cut scope +- [docx2md-impl-spec.md](./docx2md-impl-spec.md) + - implementation-aligned behavior, internal structure, and concrete processing rules +- [upstream.md](./upstream.md) + - policy for referring to the sibling upstream app `miku-xlsx2md` + +If implementation behavior and high-level intention differ, this document should record the actual behavior explicitly. + +## 3. Intended Coverage + +Once implementation starts, this document should be updated to describe at least: + +1. overall processing flow +2. ZIP reading behavior +3. XML parsing behavior +4. relationship resolution behavior +5. style resolution behavior +6. numbering and list resolution behavior +7. heading detection behavior +8. hyperlink resolution behavior +9. table parsing behavior +10. Markdown rendering behavior +11. summary and diagnostic behavior +12. browser UI / CLI behavior when implemented + +## 4. Current Implementation Status + +At the moment, the implementation is not yet established. +Therefore, this document does not describe current code behavior yet. + +Until the first cut exists, the authoritative source for design intent remains: + +- [docx2md-spec.md](./docx2md-spec.md) + +## 5. Sections To Fill During Implementation + +The following sections are expected to be expanded as code is added. + +### 5.1 Overall Flow + +To be filled with the concrete end-to-end flow, for example: + +1. read `.docx` +2. expand ZIP entries +3. load required XML +4. build internal document model +5. render Markdown +6. emit summary / diagnostics + +### 5.2 ZIP Handling + +To record: + +- supported ZIP methods +- entry enumeration strategy +- path normalization behavior +- error handling for invalid archives + +### 5.3 XML Utilities + +To record: + +- DOM parsing behavior +- namespace handling strategy +- text extraction helpers +- line-break and whitespace handling at XML-read time + +### 5.4 Relationship Resolution + +To record: + +- `document.xml.rels` parsing +- hyperlink relationship resolution +- path normalization rules +- broken or missing relationship fallback behavior + +### 5.5 Style Resolution + +To record: + +- paragraph style lookup +- character style lookup +- direct formatting precedence +- `basedOn` inheritance traversal +- cycle detection behavior + +### 5.6 Heading Detection + +To record: + +- `pStyle` resolution behavior +- outline-level fallback behavior +- localized heading-name compatibility +- exact heading-level mapping used by code + +### 5.7 Numbering and Lists + +To record: + +- `numbering.xml` model +- `numId` and `ilvl` resolution +- unordered vs ordered list detection +- nested list rendering details +- fallback when numbering data is broken + +### 5.8 Hyperlinks and Anchors + +To record: + +- external hyperlink rendering +- internal anchor resolution +- anchor name normalization +- fallback behavior for unresolved targets + +### 5.9 Tables + +To record: + +- table model +- row and cell traversal +- merged-cell detection +- placeholder rendering with `←M←` / `↑M↑` +- cell-paragraph joining rules + +### 5.10 Markdown Rendering + +To record: + +- paragraph rendering +- inline formatting wrapper order +- line-break normalization +- whitespace normalization +- block separation rules + +### 5.11 Unsupported Elements and Debug Output + +To record: + +- which elements are treated as unsupported +- whether they are ignored or traced +- debug switch behavior +- HTML comment trace format + +### 5.12 Summary and Diagnostics + +To record: + +- summary fields +- counting rules +- unsupported-element counts +- comment-trace counts + +## 6. Known Implementation Questions + +The following are still expected to be finalized during implementation work: + +- exact debug option name for unsupported-comment output +- internal anchor normalization rules +- nested-list indentation width +- exact representation of list-like or paragraph-like content inside table cells +- summary presentation surface in UI and/or CLI + +These items should move from “question” to “documented behavior” as soon as code decisions are made. diff --git a/docs/docx2md-spec.md b/docs/docx2md-spec.md new file mode 100644 index 0000000..4bcdba1 --- /dev/null +++ b/docs/docx2md-spec.md @@ -0,0 +1,548 @@ +# docx2md Specification + +## 1. Document Overview + +`docx2md` is a tool that reads Word documents in `.docx` format and converts their textual structure into Markdown. + +The goal is not to reproduce the visual appearance of Microsoft Word documents exactly. +The goal is to extract document structure and meaningful text in a form that is easy for humans to read and easy for generative AI systems to consume. + +This tool should be designed as: + +- `Word document structure extraction -> Markdown` + +not simply: + +- `Word appearance reproduction -> Markdown` + +This document describes the high-level specification and design policy. +Implementation-specific behavior should be documented separately in an implementation specification once the first cut exists. + +## 2. Scope + +### 2.1 Supported Input + +- `.docx` + +### 2.2 Unsupported Input + +The first cut does not target: + +- `.doc` +- `.rtf` +- `.odt` +- pasted rich text or clipboard input + +### 2.3 Supported Content in First Cut + +The first cut should focus on textual and structural content that maps cleanly into Markdown. + +- paragraphs +- headings +- inline text runs +- bold / italic / strike / underline +- line breaks inside paragraphs +- hyperlinks +- bullet lists +- numbered lists +- tables + +### 2.4 Out of Scope in First Cut + +The first cut intentionally excludes visual and layout-heavy features. + +- images +- shapes +- SmartArt +- WordArt +- text boxes +- floating objects +- charts +- drawing objects +- exact page layout reproduction +- headers / footers +- footnotes / endnotes +- comments +- tracked changes +- macros + +These may be considered later, but should not complicate the first implementation. + +## 3. Target Documents + +`docx2md` primarily targets document-style inputs whose main value is in the written content and document hierarchy. + +- specifications +- design documents +- manuals +- reports +- meeting notes +- narrative business documents + +## 4. Design Principles + +### 4.1 Output Purpose + +The Markdown output should aim to satisfy the following: + +- easy for humans to read +- easy for generative AI to process +- preserves meaningful structure +- preserves document order +- preserves enough source traceability to understand where content came from + +### 4.2 Conversion Policy + +- prioritize semantic structure over visual fidelity +- preserve document reading order +- map Word structure into ordinary Markdown where reasonable +- prefer stable and predictable output over aggressive interpretation +- keep the first cut small and testable +- prioritize features that can be expressed naturally in GitHub-compatible Markdown / HTML +- do not force representation for features that do not fit GitHub-compatible Markdown / HTML naturally + +In other words, the conversion policy is: + +- if a Word feature can be represented reasonably in GitHub-compatible Markdown / HTML, preserve it +- if it cannot be represented reasonably in that range, do not preserve it in the first cut + +This is a deliberate output policy, not merely an implementation limitation. + +### 4.3 Relationship to `miku-xlsx2md` + +`docx2md` may reuse ideas from the sibling app `miku-xlsx2md`, especially: + +- documentation structure +- separation of core logic and UI +- browser-first local processing +- optional CLI alignment +- test-first implementation style +- ZIP container handling approach +- XML parsing style +- TypeScript-first source management +- single-file web app packaging direction where practical + +Unless there is a clear `docx`-specific reason to differ, `docx2md` should imitate `miku-xlsx2md` in overall repository structure, implementation style, naming discipline, test style, and browser/runtime separation. + +However, `docx2md` should not inherit spreadsheet-specific behavior. +There is no table-region detection problem equivalent to Excel sheet analysis. +The main parsing targets are document order, paragraph style, numbering, runs, and tables. + +### 4.4 File and Module Split Policy + +The source file split should also imitate `miku-xlsx2md` where practical. + +- use `src/ts/` as the source-of-truth implementation directory +- keep generated `src/js/` output separate from TypeScript source +- keep `main.ts` as the browser UI entry point +- keep `core.ts` as the central conversion orchestration layer +- split helper logic into small focused modules instead of concentrating everything in one large parser file + +The split should remain reasonable for the smaller `docx` scope. +It is not necessary to copy the spreadsheet app file count mechanically, but the same style should be followed: + +- ZIP handling in a dedicated module such as `zip-io.ts` +- XML helper functions in a dedicated module such as `xml-utils.ts` +- relationship parsing in a dedicated module such as `rels-parser.ts` +- style parsing in a dedicated module such as `styles-parser.ts` +- numbering/list parsing in a dedicated module +- document parsing in a dedicated module +- Markdown rendering and export helpers in dedicated modules + +In short, prefer modest responsibility-based file splitting over monolithic implementation files, while avoiding artificial fragmentation. + +## 5. Output Unit and File Structure + +### 5.1 Output Unit + +The first cut should treat one `.docx` file as one input and produce one Markdown document as the primary output. + +### 5.2 Default Output + +The primary output should be: + +- one combined Markdown file + +Since images and shapes are out of scope in the first cut, ZIP export is not required initially. + +### 5.3 Naming + +The default output file name should be based on the input document name. + +Example: + +- input: `design.docx` +- output: `design.md` + +## 6. Parsing Model + +### 6.1 Container Handling + +A `.docx` file should be treated as a ZIP package. + +As with `miku-xlsx2md`, ZIP expansion should be implemented in-house from scratch rather than delegated to an external ZIP library. +The implementation should follow the same general direction as the sibling app: + +- read the package as raw bytes +- parse ZIP headers directly +- enumerate entries in a predictable way +- decode required XML entries from the extracted byte data +- keep the ZIP handling layer separate from document parsing + +This is intended to preserve architectural consistency with `miku-xlsx2md` and keep the core parsing stack understandable and testable. + +The first cut should read at least the following package entries when present: + +- `word/document.xml` +- `word/_rels/document.xml.rels` +- `word/styles.xml` +- `word/numbering.xml` + +### 6.2 Core Internal Model + +The internal model may remain small in the first cut. + +- document +- paragraph +- run +- hyperlink +- list item +- table +- table row +- table cell + +The first cut may also keep lightweight internal metadata for: + +- bookmark or anchor targets +- internal and external hyperlink targets +- unsupported element diagnostics + +### 6.3 Reading Order + +The parser should preserve the document order found in `word/document.xml`. +This is the primary structural axis for `docx2md`. + +## 7. Markdown Conversion Rules + +### 7.1 Paragraphs + +- ordinary paragraphs become plain Markdown paragraphs +- empty paragraphs may act as paragraph separators + +### 7.2 Headings + +- heading-like paragraph styles should map to Markdown headings +- `Heading 1` -> `#` +- `Heading 2` -> `##` +- `Heading 3` -> `###` +- deeper levels may continue similarly + +Heading detection in the first cut should use both paragraph style information and outline level information. +This combined approach is more robust than relying on only one of them. + +Recommended priority: + +1. heading-equivalent paragraph style +2. outline level +3. otherwise treat as a normal paragraph + +More specifically: + +- first resolve the paragraph style through `pStyle` and `styles.xml` +- if the resolved style is a built-in heading-equivalent style, use that heading level +- if the style does not resolve to a heading but the paragraph has a valid outline level, use the outline level +- if neither indicates a heading, do not treat the paragraph as a heading + +For compatibility, heading recognition should not depend only on display labels. +The implementation should prefer structural identifiers such as `styleId` and resolved style definitions. +Localized style names may be used only as a compatibility aid. + +First-cut compatibility should include at least the following heading-style families when they can be identified safely: + +- `Heading 1` to `Heading 6` +- `見出し 1` to `見出し 6` + +Heading level mapping should be: + +- level 1 -> `#` +- level 2 -> `##` +- level 3 -> `###` +- level 4 -> `####` +- level 5 -> `#####` +- level 6 or deeper -> `######` + +The first cut should not infer headings only from appearance. +The following alone are not enough to classify a paragraph as a heading: + +- larger font size +- bold text +- centered alignment +- spacing before or after the paragraph + +### 7.3 Inline Formatting + +- bold -> `**text**` +- italic -> `*text*` +- strike -> `~~text~~` +- underline -> `text` +- paragraph-internal line breaks -> `
` + +When multiple inline styles apply to the same text, the rendering order should follow the sibling-app approach for deterministic output. +Recommended wrapper application order is: + +1. underline +2. strike +3. italic +4. bold + +This means the final visible output places `bold` outermost when all four styles are active. + +### 7.4 Hyperlinks + +- external links should become Markdown links when possible +- format: `[text](url)` +- when hyperlink text also has underline formatting, GitHub output does not need to add extra underline markup on top of the link +- document-internal links should also be preserved when the target can be resolved safely +- a resolved internal link may render as `[text](#anchor)` +- if an internal target cannot be resolved reliably, the implementation may fall back to plain text rather than emitting a broken link + +### 7.5 Lists + +- bullet lists should become `- item` +- numbered lists should become `1. item` +- nested lists are required in the first cut + +List handling in the first cut should use `numbering.xml` and paragraph numbering properties as the primary structural source. +The implementation should not rely on visual indentation alone. + +The first cut should support at least: + +- bullet lists +- numbered lists +- nested lists +- mixed nesting where a bullet list contains a numbered list or vice versa + +Recommended interpretation order: + +1. resolve the paragraph numbering reference from the paragraph properties +2. resolve the numbering instance through `numId` +3. resolve the abstract numbering definition +4. use the paragraph level such as `ilvl` to determine nesting depth +5. use the numbering definition to distinguish bullet-style and ordered-style items + +Markdown rendering policy: + +- unordered items should render as `- item` +- ordered items should render as `1. item` +- nested items should be indented in Markdown according to their nesting depth +- the output should prefer stable Markdown readability over reproducing Word numbering glyphs exactly + +The first cut does not need to reproduce every numbering style variation exactly. +For example, the following may be normalized while still preserving list structure: + +- `1.` / `1)` / `a.` / `A.` / `i.` / `I.` +- localized numbering markers +- custom bullet glyphs + +In such cases, preserving ordered vs unordered structure and nesting depth is more important than preserving the exact marker text. + +The first cut should also define clear limits: + +- list detection should be based on numbering structure, not indentation width alone +- paragraphs without list structure should not be converted into lists only because they are visually indented +- if numbering metadata is broken or incomplete, the implementation may fall back to a plain paragraph instead of inventing list structure + +### 7.6 Tables + +- simple Word tables should become Markdown tables +- table extraction is structural, not visual +- line breaks inside table cells should become `
` + +Merged cells should be simplified in the first cut using explicit merge placeholders rather than attempting HTML table reproduction. + +Recommended merge rendering policy: + +- the origin cell of a merge keeps its actual content +- a horizontally absorbed cell should render as `←M←` +- otherwise, a vertically absorbed cell should render as `↑M↑` + +In other words, merge placeholder priority should be: + +1. if there is a parent cell on the left, use `←M←` +2. otherwise, if there is a parent cell above, use `↑M↑` + +Additional first-cut table rules: + +- the Markdown table column count should follow the Word table grid as parsed +- the implementation should preserve table structure rather than attempting pixel-equivalent layout reproduction +- the first cut should not switch to raw HTML table output just to preserve merge layout + +### 7.7 Line Break and Whitespace Normalization + +Line break and whitespace normalization should prioritize stable Markdown output over layout-oriented fidelity. + +Document/block-level rules: + +- ordinary paragraphs should be separated by one blank line +- headings should be separated from surrounding blocks by one blank line +- tables should be separated from surrounding blocks by one blank line +- consecutive empty paragraphs with no meaningful content should be compressed into a single paragraph break +- leading empty paragraphs at the start of a document may be removed +- trailing empty paragraphs at the end of a document may be removed + +Paragraph-level rules: + +- explicit paragraph-internal line breaks should render as `
` +- multiple explicit line breaks may remain as repeated `
` +- paragraph text should be trimmed at both ends before final Markdown emission +- run boundaries should not insert guessed spaces automatically + +Whitespace normalization rules: + +- tabs should normalize to four spaces +- repeated ordinary spaces may be normalized to a single space in normal prose output +- normalization should preserve intentionally explicit line break structure +- whitespace normalization should favor deterministic output and test stability + +Cell-level rules: + +- a line break inside a table-cell paragraph should render as `
` +- multiple paragraphs inside one table cell may be joined using `

` +- table-cell text should be trimmed at both ends before final Markdown emission + +List-item text should follow the same general normalization policy as ordinary paragraphs unless a later implementation section defines a narrower exception. + +### 7.8 Style Resolution Depth + +The first cut should resolve only the style layers needed for structural extraction and supported inline formatting. + +Recommended priority order: + +1. direct formatting on the paragraph or run +2. character style +3. paragraph style +4. inherited style chain via `basedOn` + +Style resolution should be deep enough to support at least: + +- heading detection +- bold +- italic +- strike +- underline +- list-related paragraph structure when style metadata is relevant + +The first cut does not need to resolve every style-related visual detail. +It should prioritize structure and supported Markdown-facing emphasis rather than Word layout fidelity. + +Implementation safety rules: + +- `basedOn` chains may be followed recursively +- cyclic style references should be detected and cut off safely +- broken or missing style references should fall back gracefully to the next available source of information + +## 8. Error and Fallback Policy + +- unsupported visual features should be ignored rather than breaking the whole conversion +- parsing should fail clearly for invalid `.docx` inputs +- when a structure cannot be represented faithfully in Markdown, prefer a simple stable fallback +- when a Word feature does not map naturally to the supported GitHub-compatible Markdown/HTML subset, omission is preferable to awkward pseudo-reproduction + +This includes a deliberate output-policy compromise similar to `miku-xlsx2md`: + +- some rich text and display formatting should be preserved only when they fall naturally into GitHub-compatible Markdown / HTML +- formatting that does not fall naturally into that range should not be forced into artificial pseudo-syntax + +Unsupported elements may leave an HTML comment trace in the Markdown when that helps preserve document understanding without heavily disturbing readability. +However, this should be disabled by default in normal output. + +Recommended first-cut fallback policy for unsupported elements: + +- unsupported elements should not usually become visible prose in the Markdown body +- by default, unsupported elements should be omitted from the Markdown body +- when a debug-oriented switch is enabled, unsupported elements may be represented by lightweight HTML comments +- HTML comment traces should remain concise and diagnostic rather than verbose + +The first cut may expose this through a dedicated option such as: + +- `--debug` +- `--include-unsupported-comments` + +The exact option name may be finalized later, but the policy should be: + +- default output: no unsupported-element HTML comments +- debug-oriented output: unsupported-element HTML comments allowed + +Examples of acceptable fallback direction: + +- `` +- `` +- `` + +The first cut should prefer concise comment traces over large raw XML dumps or long explanatory blocks. + +## 9. Summary and Diagnostics + +The first cut should maintain a lightweight conversion summary and unsupported-element diagnostics, following the general sibling-app philosophy of keeping conversion behavior observable. + +Recommended summary items include at least: + +- paragraphs +- headings +- listItems +- tables +- links +- internalLinks +- externalLinks +- unsupportedElements +- unsupportedCommentTraces + +## 10. First-Cut Test Coverage + +The first cut should be validated primarily through fixture-based tests. +The fixture set should be small but intentionally representative of the supported feature boundaries. + +Recommended first-cut coverage includes at least: + +- plain paragraph extraction +- heading detection from paragraph style and/or outline level +- inline formatting for bold, italic, strike, and underline +- explicit line breaks rendered as `
` +- external hyperlink extraction +- internal hyperlink extraction when the anchor target is resolvable +- bullet list extraction +- numbered list extraction +- nested list extraction +- mixed nested list extraction +- simple table extraction +- merged-cell placeholder rendering with `←M←` and `↑M↑` +- table-cell line breaks rendered as `
` +- unsupported objects leaving concise HTML comment traces when the debug-oriented switch is enabled +- graceful fallback when unresolved internal links or broken numbering/style metadata are encountered + +The first-cut test set should prioritize deterministic Markdown output. +Tests should prefer exact-output assertions for stable representative fixtures whenever practical. + +## 11. Runtime and Packaging Direction + +The intended direction is to follow the sibling app style where practical. + +- local processing +- browser-capable implementation +- TypeScript as source of truth +- testable core logic + +CLI support may be added later, but it does not need to block the first cut. + +## 12. Initial Development Priorities + +Recommended implementation order: + +1. ZIP entry reading for `.docx` +2. plain paragraph extraction +3. inline run formatting +4. headings +5. hyperlinks +6. lists via numbering +7. tables +8. summary and tests diff --git a/docs/upstream.md b/docs/upstream.md new file mode 100644 index 0000000..a436258 --- /dev/null +++ b/docs/upstream.md @@ -0,0 +1,27 @@ +# Upstream Reference Policy + +## Purpose + +This repository develops `miku-docx2md` while referring to the sibling app `miku-xlsx2md` as an upstream design and implementation reference. + +## Upstream Repository + +The primary upstream reference is the Git clone placed at: + +- `workplace/miku-xlsx2md` + +Its origin is: + +- `https://github.com/igapyon/miku-xlsx2md` + +When reviewing architecture, documentation style, file layout, build scripts, or test strategy, prefer this clone as the canonical sibling reference. + +## Intended Usage + +- Read `workplace/miku-xlsx2md` first when considering `miku-docx2md` specifications. +- Reuse compatible ideas such as documentation structure, core/UI separation, CLI alignment, and test strategy. +- Do not assume `xlsx`-specific logic is directly reusable for `docx`; use it as a sibling reference, not as a direct specification. + +## Scope Note + +For `miku-docx2md` first cut, images and shapes are out of scope even though the upstream sibling app handles richer asset extraction in the spreadsheet domain. diff --git a/package.json b/package.json new file mode 100644 index 0000000..0554d1f --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "miku-docx2md", + "private": true, + "type": "module", + "scripts": { + "test": "vitest run", + "test:unit": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "@xmldom/xmldom": "^0.9.9", + "jsdom": "^26.0.0", + "typescript": "^5.7.3", + "vitest": "^3.0.0" + } +} diff --git a/scripts/lib/docx2md-module-order.mjs b/scripts/lib/docx2md-module-order.mjs new file mode 100644 index 0000000..918ccba --- /dev/null +++ b/scripts/lib/docx2md-module-order.mjs @@ -0,0 +1,8 @@ +export const DOCX2MD_CORE_JS_ORDER = [ + "src/js/module-registry.js", + "src/js/zip-io.js", + "src/js/xml-utils.js", + "src/js/rels-parser.js", + "src/js/document-parser.js", + "src/js/core.js" +]; diff --git a/scripts/lib/docx2md-node-runtime.mjs b/scripts/lib/docx2md-node-runtime.mjs new file mode 100644 index 0000000..c784b50 --- /dev/null +++ b/scripts/lib/docx2md-node-runtime.mjs @@ -0,0 +1,63 @@ +import fs from "node:fs"; +import { Blob as NodeBlob } from "node:buffer"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { DecompressionStream as NodeDecompressionStream } from "node:stream/web"; +import { fileURLToPath } from "node:url"; + +import { + DOMParser as XmldomParser, + XMLSerializer as XmldomSerializer, + Node as XmldomNode, + Document as XmldomDocument, + Element as XmldomElement +} from "@xmldom/xmldom"; + +import { DOCX2MD_CORE_JS_ORDER } from "./docx2md-module-order.mjs"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const DEFAULT_ROOT_DIR = path.resolve(__dirname, "../.."); +const nodeRequire = createRequire(import.meta.url); + +let cachedApi = null; +let cachedRootDir = null; + +export function installNodeDomGlobals() { + if (typeof globalThis.DOMParser !== "function") { + globalThis.DOMParser = XmldomParser; + globalThis.Node = XmldomNode; + globalThis.Document = XmldomDocument; + globalThis.Element = XmldomElement; + globalThis.XMLSerializer ??= XmldomSerializer; + } + if (typeof globalThis.Blob === "undefined" || typeof globalThis.Blob.prototype?.stream !== "function") { + globalThis.Blob = NodeBlob; + } + globalThis.DecompressionStream ??= NodeDecompressionStream; + globalThis.__docx2mdNodeRequire ??= nodeRequire; +} + +export function loadDocx2mdNodeApi(options = {}) { + const rootDir = path.resolve(options.rootDir || DEFAULT_ROOT_DIR); + if (cachedApi && cachedRootDir === rootDir) { + return cachedApi; + } + + installNodeDomGlobals(); + + for (const relPath of DOCX2MD_CORE_JS_ORDER) { + const absPath = path.resolve(rootDir, relPath); + const source = fs.readFileSync(absPath, "utf8"); + new Function(source)(); + } + + const api = globalThis.__docx2mdModuleRegistry?.getModule("docx2md"); + if (!api) { + throw new Error("docx2md core API failed to initialize."); + } + + cachedApi = api; + cachedRootDir = rootDir; + return api; +} diff --git a/src/js/core.js b/src/js/core.js new file mode 100644 index 0000000..7672ad3 --- /dev/null +++ b/src/js/core.js @@ -0,0 +1,29 @@ +/* + * Copyright 2026 Toshiki Iga + * SPDX-License-Identifier: Apache-2.0 + */ + +(() => { + const moduleRegistry = getDocx2mdModuleRegistry(); + const zipIo = moduleRegistry.getModule("zipIo"); + const documentParser = moduleRegistry.getModule("documentParser"); + + async function parseDocx(arrayBuffer) { + const files = await zipIo.unzipEntries(arrayBuffer); + const documentXmlBytes = files.get("word/document.xml"); + if (!documentXmlBytes) { + throw new Error("word/document.xml was not found."); + } + const relationshipsBytes = files.get("word/_rels/document.xml.rels"); + return documentParser.parseDocumentXml(documentXmlBytes, relationshipsBytes); + } + + function renderMarkdown(parsedDocument) { + return parsedDocument.paragraphs.map((paragraph) => paragraph.text).join("\n\n"); + } + + moduleRegistry.registerModule("docx2md", { + parseDocx, + renderMarkdown + }); +})(); diff --git a/src/js/document-parser.js b/src/js/document-parser.js new file mode 100644 index 0000000..41e2936 --- /dev/null +++ b/src/js/document-parser.js @@ -0,0 +1,63 @@ +/* + * Copyright 2026 Toshiki Iga + * SPDX-License-Identifier: Apache-2.0 + */ + +(() => { + const moduleRegistry = getDocx2mdModuleRegistry(); + const xmlUtils = moduleRegistry.getModule("xmlUtils"); + const relsParser = moduleRegistry.getModule("relsParser"); + + function extractTextRuns(paragraph, relationships) { + const pieces = []; + for (const child of Array.from(paragraph.childNodes || [])) { + if (child.nodeType !== 1) continue; + if (child.localName === "r") { + const textElements = xmlUtils.getChildrenByLocalName(child, "t"); + for (const textElement of textElements) { + pieces.push(xmlUtils.getTextContent(textElement)); + } + const breakElements = xmlUtils.getChildrenByLocalName(child, "br"); + if (breakElements.length > 0) { + pieces.push("
".repeat(breakElements.length)); + } + } else if (child.localName === "hyperlink") { + const linkText = extractTextRuns(child, relationships); + const relationshipId = child.getAttribute("r:id") || ""; + const anchor = child.getAttribute("w:anchor") || child.getAttribute("anchor") || ""; + if (relationshipId && relationships.has(relationshipId)) { + pieces.push(`[${linkText}](${relationships.get(relationshipId).target})`); + } else if (anchor) { + pieces.push(`[${linkText}](#${anchor})`); + } else { + pieces.push(linkText); + } + } + } + return pieces.join("").replace(/\t/g, " ").replace(/ {2,}/g, " ").trim(); + } + + function parseDocumentXml(documentXmlBytes, relationshipsBytes) { + const document = xmlUtils.parseXml(documentXmlBytes); + const body = xmlUtils.findDescendantsByLocalName(document, "body")[0] || null; + const relationships = relationshipsBytes ? relsParser.parseRelationships(relationshipsBytes, "word/document.xml") : new Map(); + const paragraphs = []; + if (!body) { + return { paragraphs }; + } + for (const element of xmlUtils.getChildrenByLocalName(body, "p")) { + const text = extractTextRuns(element, relationships); + if (text) { + paragraphs.push({ + kind: "paragraph", + text + }); + } + } + return { paragraphs }; + } + + moduleRegistry.registerModule("documentParser", { + parseDocumentXml + }); +})(); diff --git a/src/js/module-registry.js b/src/js/module-registry.js new file mode 100644 index 0000000..4e416c3 --- /dev/null +++ b/src/js/module-registry.js @@ -0,0 +1,24 @@ +/* + * Copyright 2026 Toshiki Iga + * SPDX-License-Identifier: Apache-2.0 + */ + +(() => { + function createModuleRegistry() { + const modules = new Map(); + return { + registerModule(name, moduleApi) { + modules.set(name, moduleApi); + }, + getModule(name) { + return modules.get(name) ?? null; + } + }; + } + + globalThis.__docx2mdModuleRegistry ??= createModuleRegistry(); + + globalThis.getDocx2mdModuleRegistry = function getDocx2mdModuleRegistry() { + return globalThis.__docx2mdModuleRegistry; + }; +})(); diff --git a/src/js/rels-parser.js b/src/js/rels-parser.js new file mode 100644 index 0000000..62d71c7 --- /dev/null +++ b/src/js/rels-parser.js @@ -0,0 +1,49 @@ +/* + * Copyright 2026 Toshiki Iga + * SPDX-License-Identifier: Apache-2.0 + */ + +(() => { + const moduleRegistry = getDocx2mdModuleRegistry(); + const xmlUtils = moduleRegistry.getModule("xmlUtils"); + + function resolveZipPath(sourcePath, target) { + if (!target) return target; + if (target.startsWith("/")) { + return target.replace(/^\/+/, ""); + } + const baseParts = sourcePath.split("/").slice(0, -1); + for (const part of target.split("/")) { + if (!part || part === ".") continue; + if (part === "..") { + baseParts.pop(); + } else { + baseParts.push(part); + } + } + return baseParts.join("/"); + } + + function parseRelationships(bytes, sourcePath) { + const document = xmlUtils.parseXml(bytes); + const relationshipElements = xmlUtils.findDescendantsByLocalName(document, "Relationship"); + const map = new Map(); + for (const element of relationshipElements) { + const id = element.getAttribute("Id") || ""; + const rawTarget = element.getAttribute("Target") || ""; + const type = element.getAttribute("Type") || ""; + const mode = element.getAttribute("TargetMode") || ""; + map.set(id, { + target: mode === "External" ? rawTarget : resolveZipPath(sourcePath, rawTarget), + type, + mode + }); + } + return map; + } + + moduleRegistry.registerModule("relsParser", { + resolveZipPath, + parseRelationships + }); +})(); diff --git a/src/js/xml-utils.js b/src/js/xml-utils.js new file mode 100644 index 0000000..4052e7b --- /dev/null +++ b/src/js/xml-utils.js @@ -0,0 +1,60 @@ +/* + * Copyright 2026 Toshiki Iga + * SPDX-License-Identifier: Apache-2.0 + */ + +(() => { + const moduleRegistry = getDocx2mdModuleRegistry(); + const textDecoder = new TextDecoder("utf-8"); + + function decodeXmlText(bytes) { + return textDecoder.decode(bytes); + } + + function parseXml(bytes) { + return new DOMParser().parseFromString(decodeXmlText(bytes), "application/xml"); + } + + function getChildrenByLocalName(parent, localName) { + const results = []; + const childNodes = parent.childNodes || []; + for (let index = 0; index < childNodes.length; index += 1) { + const child = childNodes[index]; + if (child.nodeType === 1 && child.localName === localName) { + results.push(child); + } + } + return results; + } + + function findDescendantsByLocalName(parent, localName) { + const results = []; + const stack = [parent]; + while (stack.length > 0) { + const current = stack.pop(); + const childNodes = current.childNodes || []; + for (let index = 0; index < childNodes.length; index += 1) { + const child = childNodes[index]; + if (child.nodeType === 1) { + if (child.localName === localName) { + results.push(child); + } + stack.push(child); + } + } + } + return results; + } + + function getTextContent(node) { + return String((node && node.textContent) || ""); + } + + moduleRegistry.registerModule("xmlUtils", { + decodeXmlText, + parseXml, + getChildrenByLocalName, + findDescendantsByLocalName, + getTextContent + }); +})(); diff --git a/src/js/zip-io.js b/src/js/zip-io.js new file mode 100644 index 0000000..d61951e --- /dev/null +++ b/src/js/zip-io.js @@ -0,0 +1,110 @@ +/* + * Copyright 2026 Toshiki Iga + * SPDX-License-Identifier: Apache-2.0 + */ + +(() => { + const moduleRegistry = getDocx2mdModuleRegistry(); + const textDecoder = new TextDecoder("utf-8"); + const nodeRequire = (() => { + const candidate = globalThis.__docx2mdNodeRequire; + return typeof candidate === "function" ? candidate : null; + })(); + + function readUint16LE(view, offset) { + return view.getUint16(offset, true); + } + + function readUint32LE(view, offset) { + return view.getUint32(offset, true); + } + + function decodeFileName(bytes) { + return textDecoder.decode(bytes); + } + + async function inflateRaw(data) { + if (typeof DecompressionStream === "function") { + try { + const stream = new Blob([data]).stream().pipeThrough(new DecompressionStream("deflate-raw")); + const buffer = await new Response(stream).arrayBuffer(); + return new Uint8Array(buffer); + } catch (_error) { + // Fall through to node:zlib if available. + } + } + if (nodeRequire) { + const zlib = nodeRequire("node:zlib"); + return Uint8Array.from(zlib.inflateRawSync(data)); + } + throw new Error("This environment does not support ZIP deflate decompression."); + } + + async function unzipEntries(arrayBuffer) { + const view = new DataView(arrayBuffer); + let eocdOffset = -1; + for (let offset = view.byteLength - 22; offset >= Math.max(0, view.byteLength - 0x10000 - 22); offset -= 1) { + if (readUint32LE(view, offset) === 0x06054b50) { + eocdOffset = offset; + break; + } + } + if (eocdOffset < 0) { + throw new Error("ZIP end-of-central-directory record was not found."); + } + + const centralDirectorySize = readUint32LE(view, eocdOffset + 12); + const centralDirectoryOffset = readUint32LE(view, eocdOffset + 16); + const endOffset = centralDirectoryOffset + centralDirectorySize; + const entries = []; + let cursor = centralDirectoryOffset; + + while (cursor < endOffset) { + if (readUint32LE(view, cursor) !== 0x02014b50) { + throw new Error("ZIP central directory format is invalid."); + } + const compressionMethod = readUint16LE(view, cursor + 10); + const compressedSize = readUint32LE(view, cursor + 20); + const fileNameLength = readUint16LE(view, cursor + 28); + const extraFieldLength = readUint16LE(view, cursor + 30); + const fileCommentLength = readUint16LE(view, cursor + 32); + const localHeaderOffset = readUint32LE(view, cursor + 42); + const fileNameBytes = new Uint8Array(arrayBuffer, cursor + 46, fileNameLength); + entries.push({ + name: decodeFileName(fileNameBytes), + compressionMethod, + compressedSize, + localHeaderOffset + }); + cursor += 46 + fileNameLength + extraFieldLength + fileCommentLength; + } + + const files = new Map(); + for (const entry of entries) { + const localOffset = entry.localHeaderOffset; + if (readUint32LE(view, localOffset) !== 0x04034b50) { + throw new Error(`ZIP local header is invalid: ${entry.name}`); + } + const fileNameLength = readUint16LE(view, localOffset + 26); + const extraFieldLength = readUint16LE(view, localOffset + 28); + const dataOffset = localOffset + 30 + fileNameLength + extraFieldLength; + const compressedData = new Uint8Array(arrayBuffer, dataOffset, entry.compressedSize); + + let fileData; + if (entry.compressionMethod === 0) { + fileData = new Uint8Array(compressedData); + } else if (entry.compressionMethod === 8) { + fileData = await inflateRaw(compressedData); + } else { + throw new Error(`Unsupported compression method: ${entry.name} (method=${entry.compressionMethod})`); + } + + files.set(entry.name, fileData); + } + return files; + } + + moduleRegistry.registerModule("zipIo", { + unzipEntries + }); +})(); diff --git a/src/ts/core.ts b/src/ts/core.ts new file mode 100644 index 0000000..58ad92f --- /dev/null +++ b/src/ts/core.ts @@ -0,0 +1,38 @@ +/* + * Copyright 2026 Toshiki Iga + * SPDX-License-Identifier: Apache-2.0 + */ + +(() => { + const moduleRegistry = getDocx2mdModuleRegistry(); + const zipIo = moduleRegistry.getModule<{ + unzipEntries: (arrayBuffer: ArrayBuffer) => Promise>; + }>("zipIo"); + const documentParser = moduleRegistry.getModule<{ + parseDocumentXml: (documentXmlBytes: Uint8Array, relationshipsBytes?: Uint8Array) => { paragraphs: { kind: "paragraph"; text: string }[] }; + }>("documentParser"); + + async function parseDocx(arrayBuffer: ArrayBuffer): Promise<{ paragraphs: { kind: "paragraph"; text: string }[] }> { + const files = await zipIo?.unzipEntries(arrayBuffer); + if (!files) { + throw new Error("ZIP module is not loaded."); + } + const documentXmlBytes = files.get("word/document.xml"); + if (!documentXmlBytes) { + throw new Error("word/document.xml was not found."); + } + const relationshipsBytes = files.get("word/_rels/document.xml.rels"); + return documentParser?.parseDocumentXml(documentXmlBytes, relationshipsBytes) || { paragraphs: [] }; + } + + function renderMarkdown(parsedDocument: { paragraphs: { kind: "paragraph"; text: string }[] }): string { + return parsedDocument.paragraphs + .map((paragraph) => paragraph.text) + .join("\n\n"); + } + + moduleRegistry.registerModule("docx2md", { + parseDocx, + renderMarkdown + }); +})(); diff --git a/src/ts/document-parser.ts b/src/ts/document-parser.ts new file mode 100644 index 0000000..5454fd4 --- /dev/null +++ b/src/ts/document-parser.ts @@ -0,0 +1,84 @@ +/* + * Copyright 2026 Toshiki Iga + * SPDX-License-Identifier: Apache-2.0 + */ + +(() => { + const moduleRegistry = getDocx2mdModuleRegistry(); + const xmlUtils = moduleRegistry.getModule<{ + parseXml: (bytes: Uint8Array) => Document; + getChildrenByLocalName: (parent: ParentNode, localName: string) => Element[]; + findDescendantsByLocalName: (parent: ParentNode, localName: string) => Element[]; + getTextContent: (node: Node | null | undefined) => string; + }>("xmlUtils"); + const relsParser = moduleRegistry.getModule<{ + parseRelationships: (bytes: Uint8Array, sourcePath: string) => Map; + }>("relsParser"); + + type ParsedParagraph = { + kind: "paragraph"; + text: string; + }; + + type ParsedDocument = { + paragraphs: ParsedParagraph[]; + }; + + function getFirstChildByLocalName(parent: ParentNode, localName: string): Element | null { + return xmlUtils?.getChildrenByLocalName(parent, localName)[0] || null; + } + + function extractTextRuns(paragraph: Element, relationships: Map): string { + const pieces: string[] = []; + for (const child of Array.from(paragraph.childNodes || [])) { + if (child.nodeType !== 1) continue; + const element = child as Element; + if (element.localName === "r") { + const textElements = xmlUtils?.getChildrenByLocalName(element, "t") || []; + for (const textElement of textElements) { + pieces.push(xmlUtils?.getTextContent(textElement) || ""); + } + const breakElements = xmlUtils?.getChildrenByLocalName(element, "br") || []; + if (breakElements.length > 0) { + pieces.push("
".repeat(breakElements.length)); + } + } else if (element.localName === "hyperlink") { + const linkText = extractTextRuns(element, relationships); + const relationshipId = element.getAttribute("r:id") || ""; + const anchor = element.getAttribute("w:anchor") || element.getAttribute("anchor") || ""; + if (relationshipId && relationships.has(relationshipId)) { + pieces.push(`[${linkText}](${relationships.get(relationshipId)?.target || ""})`); + } else if (anchor) { + pieces.push(`[${linkText}](#${anchor})`); + } else { + pieces.push(linkText); + } + } + } + return pieces.join("").replace(/\t/g, " ").replace(/ {2,}/g, " ").trim(); + } + + function parseDocumentXml(documentXmlBytes: Uint8Array, relationshipsBytes?: Uint8Array): ParsedDocument { + const document = xmlUtils?.parseXml(documentXmlBytes); + const body = document ? xmlUtils?.findDescendantsByLocalName(document, "body")[0] : null; + const relationships = relationshipsBytes ? relsParser?.parseRelationships(relationshipsBytes, "word/document.xml") || new Map() : new Map(); + const paragraphs: ParsedParagraph[] = []; + if (!body) { + return { paragraphs }; + } + for (const element of xmlUtils?.getChildrenByLocalName(body, "p") || []) { + const text = extractTextRuns(element, relationships); + if (text) { + paragraphs.push({ + kind: "paragraph", + text + }); + } + } + return { paragraphs }; + } + + moduleRegistry.registerModule("documentParser", { + parseDocumentXml + }); +})(); diff --git a/src/ts/module-registry.ts b/src/ts/module-registry.ts new file mode 100644 index 0000000..de29e2c --- /dev/null +++ b/src/ts/module-registry.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2026 Toshiki Iga + * SPDX-License-Identifier: Apache-2.0 + */ + +(() => { + type ModuleRegistry = { + registerModule: (name: string, moduleApi: unknown) => void; + getModule: (name: string) => T | null; + }; + + function createModuleRegistry(): ModuleRegistry { + const modules = new Map(); + return { + registerModule(name, moduleApi) { + modules.set(name, moduleApi); + }, + getModule(name) { + return (modules.get(name) ?? null) as never; + } + }; + } + + const globalObject = globalThis as typeof globalThis & { + __docx2mdModuleRegistry?: ModuleRegistry; + }; + + globalObject.__docx2mdModuleRegistry ??= createModuleRegistry(); + + globalObject.getDocx2mdModuleRegistry = function getDocx2mdModuleRegistry(): ModuleRegistry { + return globalObject.__docx2mdModuleRegistry as ModuleRegistry; + }; +})(); diff --git a/src/ts/rels-parser.ts b/src/ts/rels-parser.ts new file mode 100644 index 0000000..9515c55 --- /dev/null +++ b/src/ts/rels-parser.ts @@ -0,0 +1,52 @@ +/* + * Copyright 2026 Toshiki Iga + * SPDX-License-Identifier: Apache-2.0 + */ + +(() => { + const moduleRegistry = getDocx2mdModuleRegistry(); + const xmlUtils = moduleRegistry.getModule<{ + parseXml: (bytes: Uint8Array) => Document; + findDescendantsByLocalName: (parent: ParentNode, localName: string) => Element[]; + }>("xmlUtils"); + + function resolveZipPath(sourcePath: string, target: string): string { + if (!target) return target; + if (target.startsWith("/")) { + return target.replace(/^\/+/, ""); + } + const baseParts = sourcePath.split("/").slice(0, -1); + for (const part of target.split("/")) { + if (!part || part === ".") continue; + if (part === "..") { + baseParts.pop(); + } else { + baseParts.push(part); + } + } + return baseParts.join("/"); + } + + function parseRelationships(bytes: Uint8Array, sourcePath: string): Map { + const document = xmlUtils?.parseXml(bytes); + const relationshipElements = document ? xmlUtils?.findDescendantsByLocalName(document, "Relationship") || [] : []; + const map = new Map(); + for (const element of relationshipElements) { + const id = element.getAttribute("Id") || ""; + const rawTarget = element.getAttribute("Target") || ""; + const type = element.getAttribute("Type") || ""; + const mode = element.getAttribute("TargetMode") || ""; + map.set(id, { + target: mode === "External" ? rawTarget : resolveZipPath(sourcePath, rawTarget), + type, + mode + }); + } + return map; + } + + moduleRegistry.registerModule("relsParser", { + resolveZipPath, + parseRelationships + }); +})(); diff --git a/src/ts/xml-utils.ts b/src/ts/xml-utils.ts new file mode 100644 index 0000000..a27b0cd --- /dev/null +++ b/src/ts/xml-utils.ts @@ -0,0 +1,62 @@ +/* + * Copyright 2026 Toshiki Iga + * SPDX-License-Identifier: Apache-2.0 + */ + +(() => { + const moduleRegistry = getDocx2mdModuleRegistry(); + const textDecoder = new TextDecoder("utf-8"); + + function decodeXmlText(bytes: Uint8Array): string { + return textDecoder.decode(bytes); + } + + function parseXml(bytes: Uint8Array): Document { + const xmlText = decodeXmlText(bytes); + return new DOMParser().parseFromString(xmlText, "application/xml"); + } + + function getChildrenByLocalName(parent: ParentNode, localName: string): Element[] { + const results: Element[] = []; + const childNodes = parent.childNodes || []; + for (let index = 0; index < childNodes.length; index += 1) { + const child = childNodes[index]; + if (child.nodeType === 1 && (child as Element).localName === localName) { + results.push(child as Element); + } + } + return results; + } + + function findDescendantsByLocalName(parent: ParentNode, localName: string): Element[] { + const results: Element[] = []; + const stack: ParentNode[] = [parent]; + while (stack.length > 0) { + const current = stack.pop() as ParentNode; + const childNodes = current.childNodes || []; + for (let index = 0; index < childNodes.length; index += 1) { + const child = childNodes[index]; + if (child.nodeType === 1) { + const element = child as Element; + if (element.localName === localName) { + results.push(element); + } + stack.push(element); + } + } + } + return results; + } + + function getTextContent(node: Node | null | undefined): string { + return String(node?.textContent || ""); + } + + moduleRegistry.registerModule("xmlUtils", { + decodeXmlText, + parseXml, + getChildrenByLocalName, + findDescendantsByLocalName, + getTextContent + }); +})(); diff --git a/src/ts/zip-io.ts b/src/ts/zip-io.ts new file mode 100644 index 0000000..2217d88 --- /dev/null +++ b/src/ts/zip-io.ts @@ -0,0 +1,119 @@ +/* + * Copyright 2026 Toshiki Iga + * SPDX-License-Identifier: Apache-2.0 + */ + +(() => { + const moduleRegistry = getDocx2mdModuleRegistry(); + const textDecoder = new TextDecoder("utf-8"); + const nodeRequire = (() => { + const candidate = (globalThis as typeof globalThis & { + __docx2mdNodeRequire?: ((id: string) => unknown) | undefined; + }).__docx2mdNodeRequire; + return typeof candidate === "function" ? candidate : null; + })(); + + type ZipEntryRecord = { + name: string; + compressionMethod: number; + compressedSize: number; + localHeaderOffset: number; + }; + + function readUint16LE(view: DataView, offset: number): number { + return view.getUint16(offset, true); + } + + function readUint32LE(view: DataView, offset: number): number { + return view.getUint32(offset, true); + } + + function decodeFileName(bytes: Uint8Array): string { + return textDecoder.decode(bytes); + } + + async function inflateRaw(data: Uint8Array): Promise { + if (typeof DecompressionStream === "function") { + try { + const stream = new Blob([data]).stream().pipeThrough(new DecompressionStream("deflate-raw")); + const buffer = await new Response(stream).arrayBuffer(); + return new Uint8Array(buffer); + } catch (_error) { + // Fall through to node:zlib if available. + } + } + if (nodeRequire) { + const zlib = nodeRequire("node:zlib") as { inflateRawSync: (input: Uint8Array) => Uint8Array }; + return Uint8Array.from(zlib.inflateRawSync(data)); + } + throw new Error("This environment does not support ZIP deflate decompression."); + } + + async function unzipEntries(arrayBuffer: ArrayBuffer): Promise> { + const view = new DataView(arrayBuffer); + let eocdOffset = -1; + for (let offset = view.byteLength - 22; offset >= Math.max(0, view.byteLength - 0x10000 - 22); offset -= 1) { + if (readUint32LE(view, offset) === 0x06054b50) { + eocdOffset = offset; + break; + } + } + if (eocdOffset < 0) { + throw new Error("ZIP end-of-central-directory record was not found."); + } + + const centralDirectorySize = readUint32LE(view, eocdOffset + 12); + const centralDirectoryOffset = readUint32LE(view, eocdOffset + 16); + const endOffset = centralDirectoryOffset + centralDirectorySize; + const entries: ZipEntryRecord[] = []; + let cursor = centralDirectoryOffset; + + while (cursor < endOffset) { + if (readUint32LE(view, cursor) !== 0x02014b50) { + throw new Error("ZIP central directory format is invalid."); + } + const compressionMethod = readUint16LE(view, cursor + 10); + const compressedSize = readUint32LE(view, cursor + 20); + const fileNameLength = readUint16LE(view, cursor + 28); + const extraFieldLength = readUint16LE(view, cursor + 30); + const fileCommentLength = readUint16LE(view, cursor + 32); + const localHeaderOffset = readUint32LE(view, cursor + 42); + const fileNameBytes = new Uint8Array(arrayBuffer, cursor + 46, fileNameLength); + entries.push({ + name: decodeFileName(fileNameBytes), + compressionMethod, + compressedSize, + localHeaderOffset + }); + cursor += 46 + fileNameLength + extraFieldLength + fileCommentLength; + } + + const files = new Map(); + for (const entry of entries) { + const localOffset = entry.localHeaderOffset; + if (readUint32LE(view, localOffset) !== 0x04034b50) { + throw new Error(`ZIP local header is invalid: ${entry.name}`); + } + const fileNameLength = readUint16LE(view, localOffset + 26); + const extraFieldLength = readUint16LE(view, localOffset + 28); + const dataOffset = localOffset + 30 + fileNameLength + extraFieldLength; + const compressedData = new Uint8Array(arrayBuffer, dataOffset, entry.compressedSize); + + let fileData: Uint8Array; + if (entry.compressionMethod === 0) { + fileData = new Uint8Array(compressedData); + } else if (entry.compressionMethod === 8) { + fileData = await inflateRaw(compressedData); + } else { + throw new Error(`Unsupported compression method: ${entry.name} (method=${entry.compressionMethod})`); + } + + files.set(entry.name, fileData); + } + return files; + } + + moduleRegistry.registerModule("zipIo", { + unzipEntries + }); +})(); diff --git a/tests/docx2md-node-runtime.test.js b/tests/docx2md-node-runtime.test.js new file mode 100644 index 0000000..6397f4f --- /dev/null +++ b/tests/docx2md-node-runtime.test.js @@ -0,0 +1,153 @@ +// @vitest-environment node + +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import { loadDocx2mdNodeApi } from "../scripts/lib/docx2md-node-runtime.mjs"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +function createStoredZip(entries) { + const encoder = new TextEncoder(); + const localChunks = []; + const centralChunks = []; + let offset = 0; + + for (const entry of entries) { + const nameBytes = encoder.encode(entry.name); + const dataBytes = entry.data; + + const localHeader = new Uint8Array(30 + nameBytes.length); + const localView = new DataView(localHeader.buffer); + localView.setUint32(0, 0x04034b50, true); + localView.setUint16(4, 20, true); + localView.setUint16(6, 0, true); + localView.setUint16(8, 0, true); + localView.setUint16(10, 0, true); + localView.setUint16(12, 0, true); + localView.setUint32(14, 0, true); + localView.setUint32(18, dataBytes.length, true); + localView.setUint32(22, dataBytes.length, true); + localView.setUint16(26, nameBytes.length, true); + localView.setUint16(28, 0, true); + localHeader.set(nameBytes, 30); + localChunks.push(localHeader, dataBytes); + + const centralHeader = new Uint8Array(46 + nameBytes.length); + const centralView = new DataView(centralHeader.buffer); + centralView.setUint32(0, 0x02014b50, true); + centralView.setUint16(4, 20, true); + centralView.setUint16(6, 20, true); + centralView.setUint16(8, 0, true); + centralView.setUint16(10, 0, true); + centralView.setUint16(12, 0, true); + centralView.setUint16(14, 0, true); + centralView.setUint32(16, 0, true); + centralView.setUint32(20, dataBytes.length, true); + centralView.setUint32(24, dataBytes.length, true); + centralView.setUint16(28, nameBytes.length, true); + centralView.setUint16(30, 0, true); + centralView.setUint16(32, 0, true); + centralView.setUint16(34, 0, true); + centralView.setUint16(36, 0, true); + centralView.setUint32(38, 0, true); + centralView.setUint32(42, offset, true); + centralHeader.set(nameBytes, 46); + centralChunks.push(centralHeader); + + offset += localHeader.length + dataBytes.length; + } + + const centralDirectoryOffset = offset; + let centralDirectorySize = 0; + for (const chunk of centralChunks) { + centralDirectorySize += chunk.length; + } + + const eocd = new Uint8Array(22); + const eocdView = new DataView(eocd.buffer); + eocdView.setUint32(0, 0x06054b50, true); + eocdView.setUint16(4, 0, true); + eocdView.setUint16(6, 0, true); + eocdView.setUint16(8, entries.length, true); + eocdView.setUint16(10, entries.length, true); + eocdView.setUint32(12, centralDirectorySize, true); + eocdView.setUint32(16, centralDirectoryOffset, true); + eocdView.setUint16(20, 0, true); + + const totalLength = localChunks.reduce((sum, chunk) => sum + chunk.length, 0) + + centralChunks.reduce((sum, chunk) => sum + chunk.length, 0) + + eocd.length; + const out = new Uint8Array(totalLength); + let cursor = 0; + for (const chunk of localChunks) { + out.set(chunk, cursor); + cursor += chunk.length; + } + for (const chunk of centralChunks) { + out.set(chunk, cursor); + cursor += chunk.length; + } + out.set(eocd, cursor); + return out.buffer.slice(out.byteOffset, out.byteOffset + out.byteLength); +} + +function createMinimalDocxArrayBuffer() { + const encoder = new TextEncoder(); + return createStoredZip([ + { + name: "word/document.xml", + data: encoder.encode( + ` + + + + Hello + world + + + + OpenAI + + + + + Jump + + + +` + ) + }, + { + name: "word/_rels/document.xml.rels", + data: encoder.encode( + ` + + +` + ) + } + ]); +} + +describe("docx2md node runtime", () => { + it("loads the core api and parses a minimal docx document", async () => { + const api = loadDocx2mdNodeApi({ + rootDir: path.resolve(__dirname, "..") + }); + expect(globalThis.__docx2mdModuleRegistry?.getModule("docx2md")).toBe(api); + + const parsed = await api.parseDocx(createMinimalDocxArrayBuffer()); + const markdown = api.renderMarkdown(parsed); + + expect(parsed.paragraphs).toHaveLength(3); + expect(markdown).toContain("Hello world"); + expect(markdown).toContain("[OpenAI](https://openai.com)"); + expect(markdown).toContain("[Jump](#section-1)"); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c1f5e92 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": [ + "src/ts/**/*.ts" + ] +} diff --git a/workplace/.gitkeep b/workplace/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/workplace/.gitkeep @@ -0,0 +1 @@ + From 3044c13fa91eb9a7327101f51b27c2108133d591 Mon Sep 17 00:00:00 2001 From: Toshiki Iga Date: Fri, 24 Apr 2026 09:58:25 +0900 Subject: [PATCH 2/5] =?UTF-8?q?docx2md=20=E3=81=AE=E5=88=9D=E5=9B=9E?= =?UTF-8?q?=E5=AE=9F=E8=A3=85=E3=82=92=E6=8B=A1=E5=BC=B5=E3=81=97=E3=80=81?= =?UTF-8?q?CLI=E3=83=BB=E3=83=96=E3=83=A9=E3=82=A6=E3=82=B6UI=E3=83=BB?= =?UTF-8?q?=E6=96=87=E6=9B=B8=E6=A7=8B=E9=80=A0=E5=A4=89=E6=8F=9B=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 `miku-docx2md` の実装を拡張し、`.docx` から Markdown への変換機能を強化しました。あわせて Node.js CLI、ブラウザ UI、ビルドスクリプト、関連ドキュメント、テストを追加・更新しています。 ## 変更内容 ### 変換処理の拡張 - 見出し判定を追加 - 太字、斜体、取り消し線、下線、段落内改行のインライン変換を追加 - 外部リンクと内部リンクの変換を追加 - ブックマークをアンカーとして出力する処理を追加 - `numbering.xml` を用いたネスト付きリストの変換を追加 - テーブル変換を追加 - 結合セルの簡易表現として `←M←` / `↑M↑` を追加 - テーブルセル内のリスト風段落を簡易的なインライン表現で保持する処理を追加 - unsupported 要素の集計と、HTML コメントによるデバッグ出力を追加 - summary 生成処理を追加 ### UI / CLI / ビルド導線の追加 - Node.js CLI を追加 - ブラウザ UI のエントリと画面実装を追加 - `index-src.html` から `index.html` を生成するビルドスクリプトを追加 - `src/ts/` から `src/js/` を生成するビルド導線を追加 - `lht-cmn` ベースの UI コンポーネント群および関連アセットを追加 - `src/css/app.css` を追加 - `package-lock.json` を追加 - `.gitignore` に `node_modules/` を追加 ## ドキュメント更新 - `README.md` を更新し、実装済み機能、CLI、ブラウザ UI、ビルド方法を追記 - `docs/docx2md-impl-spec.md` を実装反映の内容に更新 - `docs/docx2md-spec.md` にアンカー正規化やテーブルセル内表現の方針を追記 - `TODO.md` を更新し、完了済み項目と今後の refinement 項目を整理 ## テスト - Node runtime テストを拡張 - CLI テストを追加 - `lht-cmn` のコンポーネントテストを追加 --- .gitignore | 1 + README.md | 63 +- TODO.md | 34 +- docs/docx2md-impl-spec.md | 292 +- docs/docx2md-spec.md | 9 + index-src.html | 83 + index.html | 4418 +++++++++++++++++ lht-cmn/FEEDBACK.md | 54 + lht-cmn/LICENSE | 197 + lht-cmn/NOTICE | 11 + lht-cmn/README.md | 408 ++ lht-cmn/catalog/README.md | 16 + lht-cmn/catalog/catalog.css | 346 ++ lht-cmn/catalog/catalog.js | 150 + lht-cmn/catalog/index.html | 462 ++ lht-cmn/components.test.js | 630 +++ lht-cmn/css/components.css | 1049 ++++ lht-cmn/js/components.js | 1664 +++++++ ...material-web-outlined-text-field.bundle.js | 459 ++ package-lock.json | 2115 ++++++++ package.json | 4 +- scripts/build-miku-docx2md.mjs | 72 + scripts/lib/docx2md-module-order.mjs | 14 + scripts/lib/single-html.mjs | 69 + scripts/miku-docx2md-cli.mjs | 128 + src/css/app.css | 137 + src/js/core.js | 124 +- src/js/document-parser.js | 368 +- src/js/main.js | 206 + src/js/module-registry.js | 34 +- src/js/numbering-parser.js | 74 + src/js/rels-parser.js | 81 +- src/js/styles-parser.js | 57 + src/js/xml-utils.js | 97 +- src/js/zip-io.js | 189 +- src/ts/core.ts | 160 +- src/ts/document-parser.ts | 377 +- src/ts/main.ts | 284 ++ src/ts/numbering-parser.ts | 97 + src/ts/styles-parser.ts | 69 + tests/docx2md-cli.test.js | 156 + tests/docx2md-node-runtime.test.js | 190 +- 42 files changed, 14984 insertions(+), 464 deletions(-) create mode 100644 index-src.html create mode 100644 index.html create mode 100755 lht-cmn/FEEDBACK.md create mode 100755 lht-cmn/LICENSE create mode 100755 lht-cmn/NOTICE create mode 100755 lht-cmn/README.md create mode 100755 lht-cmn/catalog/README.md create mode 100755 lht-cmn/catalog/catalog.css create mode 100755 lht-cmn/catalog/catalog.js create mode 100755 lht-cmn/catalog/index.html create mode 100755 lht-cmn/components.test.js create mode 100755 lht-cmn/css/components.css create mode 100755 lht-cmn/js/components.js create mode 100755 lht-cmn/vendor/material-web-outlined-text-field.bundle.js create mode 100644 package-lock.json create mode 100644 scripts/build-miku-docx2md.mjs create mode 100644 scripts/lib/single-html.mjs create mode 100644 scripts/miku-docx2md-cli.mjs create mode 100644 src/css/app.css create mode 100644 src/js/main.js create mode 100644 src/js/numbering-parser.js create mode 100644 src/js/styles-parser.js create mode 100644 src/ts/main.ts create mode 100644 src/ts/numbering-parser.ts create mode 100644 src/ts/styles-parser.ts create mode 100644 tests/docx2md-cli.test.js diff --git a/.gitignore b/.gitignore index 39d4f9a..5663257 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .DS_Store +node_modules/ workplace/* !workplace/.gitkeep diff --git a/README.md b/README.md index d80cf51..9cd2194 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,9 @@ This project follows the sibling-app direction of `miku-xlsx2md` where practical, while adapting the parsing model to Word documents. +- browser UI follows `lht-cmn/` and prefers `lht-*` components +- the page structure and visual language intentionally stay close to `miku-xlsx2md` where reasonable + ## Features Direction The first cut focuses on text-oriented Word content that maps naturally into GitHub-compatible Markdown / HTML. @@ -69,6 +72,7 @@ Current first-cut document handling direction includes: - nested list support via `numbering.xml` - table cell line breaks rendered as `
` - merged table cells simplified with `←M←` and `↑M↑` +- document bookmarks rendered as `` anchors when available - tabs normalized to four spaces - consecutive meaningless empty paragraphs compressed - unsupported elements omitted by default, with optional HTML comment traces in a debug-oriented mode @@ -77,14 +81,14 @@ Current first-cut document handling direction includes: | Item | `miku-docx2md` status | Notes | | --- | --- | --- | -| Read `.docx` files | Planned | Primary first-cut input | -| Convert document text into Markdown | Planned | Main project goal | -| Preserve headings | Planned | Style + outline level based | -| Preserve inline formatting | Planned | GitHub-compatible Markdown / HTML subset | -| Preserve external hyperlinks | Planned | Markdown links | -| Preserve document-internal hyperlinks | Planned | When anchor resolution is possible | -| Preserve nested lists | Planned | Based on numbering structure | -| Extract tables | Planned | Structural Markdown tables | +| Read `.docx` files | Implemented | Primary first-cut input | +| Convert document text into Markdown | Implemented | Main project goal | +| Preserve headings | Implemented | Style + outline level based | +| Preserve inline formatting | Implemented | GitHub-compatible Markdown / HTML subset | +| Preserve external hyperlinks | Implemented | Markdown links | +| Preserve document-internal hyperlinks | Implemented | Bookmark-based anchors are emitted as HTML anchors | +| Preserve nested lists | Implemented | Based on numbering structure | +| Extract tables | Implemented | Structural Markdown tables | | Preserve merged table layout exactly | Not supported | Uses `←M←` / `↑M↑` placeholders instead | | Extract images and shapes | Not supported in first cut | Explicitly out of scope | | Reproduce Word appearance exactly | Not supported | Structure over visual fidelity | @@ -99,12 +103,53 @@ The intended first-cut flow is: 4. Build an internal document model 5. Convert supported structure into Markdown +## Node CLI + +A basic Node.js CLI path is available in the repository. + +Options currently include: + +- `--out ` +- `--summary` +- `--summary-out ` +- `--debug` +- `--include-unsupported-comments` +- `--help` + +Example: + +```bash +npm run cli -- ./sample.docx --out ./sample.md --summary --summary-out ./sample.summary.txt +``` + +`--debug` enables unsupported-element HTML comment traces in Markdown output. + +## Browser UI + +A minimal browser UI is also available via `index.html`. + +- select a local `.docx` file +- convert explicitly after file selection +- review summary counts +- review generated Markdown +- download Markdown and summary text +- use built-in preview copy buttons +- optionally enable unsupported-element HTML comments + +Build example: + +```bash +npm run build +``` + +This regenerates `index.html` from `index-src.html` and refreshes the generated files under `src/js/`. + ## Specifications For more details, see: - High-level specification and design policy: [docs/docx2md-spec.md](./docs/docx2md-spec.md) -- Implementation-oriented specification skeleton: [docs/docx2md-impl-spec.md](./docs/docx2md-impl-spec.md) +- Implementation-oriented specification: [docs/docx2md-impl-spec.md](./docs/docx2md-impl-spec.md) - Upstream reference policy: [docs/upstream.md](./docs/upstream.md) ## Tech Direction diff --git a/TODO.md b/TODO.md index 65a6cd8..171f314 100644 --- a/TODO.md +++ b/TODO.md @@ -20,27 +20,35 @@ ## Phase 3: First-Cut Features - [x] Paragraph extraction -- [ ] Heading detection -- [ ] Inline formatting +- [x] Heading detection +- [x] Inline formatting - [x] External hyperlinks - [x] Internal hyperlinks -- [ ] Lists with nesting -- [ ] Tables -- [ ] Merge placeholders `←M←` / `↑M↑` +- [x] Lists with nesting +- [x] Tables +- [x] Merge placeholders `←M←` / `↑M↑` ## Phase 4: Diagnostics -- [ ] Summary counts -- [ ] Unsupported element diagnostics -- [ ] Debug option for unsupported HTML comments +- [x] Summary counts +- [x] Unsupported element diagnostics +- [x] Debug option for unsupported HTML comments ## Phase 5: Tests - [x] Plain paragraph fixture -- [ ] Heading fixture -- [ ] Inline formatting fixture +- [x] Heading fixture +- [x] Inline formatting fixture - [x] External hyperlink fixture - [x] Internal hyperlink fixture -- [ ] Nested list fixture -- [ ] Table fixture -- [ ] Unsupported element debug fixture +- [x] Nested list fixture +- [x] Table fixture +- [x] Unsupported element debug fixture + +## Phase 6: Post First-Cut Refinement + +- [x] Normalize internal bookmark anchors and internal hyperlink fragments +- [x] Preserve list-like paragraphs inside table cells as simplified inline list text +- [ ] Decide whether table-cell headings need dedicated rendering or current simplified text is sufficient +- [ ] Classify unsupported elements in more detail than raw local-name passthrough where useful +- [ ] Expand implementation docs for style inheritance depth and direct-format precedence diff --git a/docs/docx2md-impl-spec.md b/docs/docx2md-impl-spec.md index 4f50336..89188e5 100644 --- a/docs/docx2md-impl-spec.md +++ b/docs/docx2md-impl-spec.md @@ -2,186 +2,226 @@ ## 1. Document Role -This document is the implementation-oriented companion to `docx2md-spec.md`. +This document records the current implementation-aligned behavior of `docx2md`. -Its purpose is to record how the actual `docx2md` implementation behaves, once the first cut begins to take shape. - -This document should eventually describe: - -- implementation-aligned behavior based on the TypeScript source under `src/ts/` -- actual processing order and fallback behavior -- internal models and conversion boundaries -- places where implementation reality differs from idealized design intention - -At the current stage, this file serves as the implementation-spec skeleton. - -## 2. Relationship to Other Documents - -The intended role split is: +It complements: - [README.md](../README.md) - entry document for users and readers - [docx2md-spec.md](./docx2md-spec.md) - - high-level specification, design policy, and first-cut scope -- [docx2md-impl-spec.md](./docx2md-impl-spec.md) - - implementation-aligned behavior, internal structure, and concrete processing rules + - high-level specification and design policy - [upstream.md](./upstream.md) - policy for referring to the sibling upstream app `miku-xlsx2md` -If implementation behavior and high-level intention differ, this document should record the actual behavior explicitly. +When implementation behavior differs from idealized design intent, this document should describe the implementation behavior explicitly. -## 3. Intended Coverage +## 2. Current Implementation Scope -Once implementation starts, this document should be updated to describe at least: +The current first cut includes: -1. overall processing flow -2. ZIP reading behavior -3. XML parsing behavior -4. relationship resolution behavior -5. style resolution behavior -6. numbering and list resolution behavior -7. heading detection behavior -8. hyperlink resolution behavior -9. table parsing behavior -10. Markdown rendering behavior -11. summary and diagnostic behavior -12. browser UI / CLI behavior when implemented +- in-house `.docx` ZIP entry reading +- XML DOM parsing utilities +- relationship resolution for document hyperlinks +- style-based and outline-level heading detection +- inline formatting for bold, italic, strike, underline, and paragraph-internal `
` +- external hyperlinks and document-internal hyperlinks +- nested lists based on `numbering.xml` +- structural table extraction +- merge placeholders `←M←` and `↑M↑` +- summary counts +- unsupported-element diagnostics +- Node.js CLI +- browser UI based on `lht-cmn/` -## 4. Current Implementation Status +The current first cut still excludes: -At the moment, the implementation is not yet established. -Therefore, this document does not describe current code behavior yet. +- image extraction +- drawing / shape extraction +- exact layout reproduction +- header / footer, footnotes, comments, tracked changes -Until the first cut exists, the authoritative source for design intent remains: +## 3. Overall Flow -- [docx2md-spec.md](./docx2md-spec.md) +The current end-to-end flow is: + +1. read a `.docx` file as bytes +2. expand ZIP entries in-house +3. load `word/document.xml` +4. optionally load `word/_rels/document.xml.rels`, `word/styles.xml`, and `word/numbering.xml` +5. parse document blocks in document order +6. build a lightweight parsed document with `blocks` and `summary` +7. render Markdown +8. optionally emit summary text and unsupported debug comments + +## 4. ZIP Handling + +Current behavior: + +- ZIP expansion is performed by the project code rather than by an external ZIP library +- required entries are looked up by normalized package path +- `word/document.xml` is mandatory +- missing optional entries fall back to empty behavior rather than immediate failure + +If `word/document.xml` is missing, parsing fails with an explicit error. + +## 5. XML Utilities + +Current behavior: + +- XML bytes are decoded as UTF-8 text +- parsing uses `DOMParser` +- lookup is based on `localName` rather than namespace prefix spelling +- helper functions exist for direct-child lookup, descendant lookup, and text extraction + +Whitespace is not aggressively normalized at XML-read time. Most text normalization happens during inline extraction and Markdown rendering. + +## 6. Relationship Resolution + +Current behavior: -## 5. Sections To Fill During Implementation +- `word/_rels/document.xml.rels` is parsed into a map keyed by relationship id +- external hyperlink targets are preserved as-is +- internal package targets are resolved relative to `word/document.xml` +- missing relationship ids do not fail the whole conversion -The following sections are expected to be expanded as code is added. +For hyperlink rendering: -### 5.1 Overall Flow +- `r:id` with a known relationship becomes an external Markdown link +- missing or broken relationship ids fall back to plain link text unless a `w:anchor` target is available -To be filled with the concrete end-to-end flow, for example: +## 7. Style and Heading Resolution -1. read `.docx` -2. expand ZIP entries -3. load required XML -4. build internal document model -5. render Markdown -6. emit summary / diagnostics +Current behavior: -### 5.2 ZIP Handling +- heading detection uses both paragraph style and outline level +- direct `Heading n` / `見出し n` recognition is supported +- style resolution walks the style chain returned by `styles.xml` +- style-chain heading names and outline levels are both considered +- paragraph-local `outlineLvl` acts as a fallback when style-based heading detection does not resolve a level -To record: +Heading levels are clamped to Markdown heading range `1..6`. -- supported ZIP methods -- entry enumeration strategy -- path normalization behavior -- error handling for invalid archives +## 8. Inline Formatting -### 5.3 XML Utilities +Current behavior: -To record: +- run formatting supports bold, italic, strike, and underline +- wrapper order is: + - underline + - strike + - italic + - bold +- paragraph-internal `w:br` becomes `
` +- tabs are normalized to four spaces +- repeated spaces are compacted during inline normalization +- surrounding whitespace is trimmed at the resulting inline string level -- DOM parsing behavior -- namespace handling strategy -- text extraction helpers -- line-break and whitespace handling at XML-read time +Hyperlink text suppresses underline wrapping so that link syntax is not nested with underline output. -### 5.4 Relationship Resolution +## 9. Hyperlinks and Anchors -To record: +Current behavior: -- `document.xml.rels` parsing -- hyperlink relationship resolution -- path normalization rules -- broken or missing relationship fallback behavior +- external hyperlinks render as `[text](url)` +- internal hyperlinks with `w:anchor` render as `[text](#anchor)` +- paragraph bookmarks from `bookmarkStart` are collected as block anchor ids +- bookmark names starting with `_` are ignored +- known paragraph bookmarks render as HTML anchors before the block: + - `` +- anchor normalization trims whitespace, lowercases, collapses spaces to `-`, replaces unsupported punctuation with `-`, and collapses repeated `-` +- the same normalization is applied to bookmark owners and internal hyperlink targets so links remain aligned -### 5.5 Style Resolution +## 10. Numbering and Lists -To record: +Current behavior: -- paragraph style lookup -- character style lookup -- direct formatting precedence -- `basedOn` inheritance traversal -- cycle detection behavior +- `numbering.xml` is parsed into `abstractNum` and `num` mappings +- `numId` and `ilvl` are used to determine list kind and nesting depth +- bullet-like numbering becomes `-` +- ordered numbering becomes `1.` +- indentation width is `4 spaces` per nesting level -### 5.6 Heading Detection +If numbering metadata cannot be resolved, the paragraph falls back to ordinary paragraph behavior rather than forcing a synthetic list. -To record: +## 11. Tables -- `pStyle` resolution behavior -- outline-level fallback behavior -- localized heading-name compatibility -- exact heading-level mapping used by code +Current behavior: -### 5.7 Numbering and Lists +- tables are parsed in document order +- each row is read from `w:tr` +- each cell is read from `w:tc` +- cell text is built from paragraph content +- multiple paragraphs inside a cell are joined by `

` +- list paragraphs inside table cells are preserved as simplified inline list text using `-` or `1.` +- nested list depth inside table cells is represented with repeated `    ` +- horizontal merge placeholders use `←M←` +- vertical merge continuation placeholders use `↑M↑` +- additional horizontally spanned cells inside a vertical continuation row use `←M←` +- shorter rows are padded with empty cells so the Markdown table stays rectangular -To record: +Markdown rendering uses the first row as the header row. -- `numbering.xml` model -- `numId` and `ilvl` resolution -- unordered vs ordered list detection -- nested list rendering details -- fallback when numbering data is broken +## 12. Markdown Rendering -### 5.8 Hyperlinks and Anchors +Current behavior: -To record: +- paragraphs render as plain Markdown blocks +- headings render as `#` through `######` +- list items render with bullet or ordered markers and 4-space nesting +- tables render as Markdown tables +- unsupported blocks are omitted by default +- unsupported blocks render as HTML comments only when debug-style output is enabled -- external hyperlink rendering -- internal anchor resolution -- anchor name normalization -- fallback behavior for unresolved targets +Anchor rendering is inserted immediately before the owning paragraph, heading, or list item block. -### 5.9 Tables +## 13. Summary and Diagnostics -To record: +Current summary fields are: -- table model -- row and cell traversal -- merged-cell detection -- placeholder rendering with `←M←` / `↑M↑` -- cell-paragraph joining rules +- `paragraphs` +- `headings` +- `listItems` +- `tables` +- `links` +- `internalLinks` +- `externalLinks` +- `unsupportedElements` +- `unsupportedCommentTraces` -### 5.10 Markdown Rendering +`unsupportedCommentTraces` currently mirrors `unsupportedElements`. -To record: +## 14. Browser UI and CLI -- paragraph rendering -- inline formatting wrapper order -- line-break normalization -- whitespace normalization -- block separation rules +### 14.1 Browser UI -### 5.11 Unsupported Elements and Debug Output +Current browser UI behavior: -To record: +- uses `lht-cmn/` as the shared component base +- uses `lht-page-hero`, `lht-file-select`, `lht-switch-help`, `lht-preview-output`, `lht-loading-overlay`, `lht-error-alert`, and `lht-toast` +- allows file selection first and explicit conversion second +- supports Markdown save and summary save +- uses `lht-preview-output` built-in copy actions for preview text +- toggles unsupported HTML comments via a switch -- which elements are treated as unsupported -- whether they are ignored or traced -- debug switch behavior -- HTML comment trace format +Page-local CSS lives in `src/css/app.css`. -### 5.12 Summary and Diagnostics +### 14.2 Node.js CLI -To record: +Current CLI options include: -- summary fields -- counting rules -- unsupported-element counts -- comment-trace counts +- `--out ` +- `--summary` +- `--summary-out ` +- `--debug` +- `--include-unsupported-comments` +- `--help` -## 6. Known Implementation Questions +`--debug` and `--include-unsupported-comments` currently enable the same Markdown behavior. -The following are still expected to be finalized during implementation work: +## 15. Open Items -- exact debug option name for unsupported-comment output -- internal anchor normalization rules -- nested-list indentation width -- exact representation of list-like or paragraph-like content inside table cells -- summary presentation surface in UI and/or CLI +The main remaining implementation questions are: -These items should move from “question” to “documented behavior” as soon as code decisions are made. +- whether table-cell headings or other richer block structures need dedicated rendering beyond simplified inline text +- broader unsupported-element classification detail +- fuller implementation-aligned documentation for `styles.xml` inheritance depth diff --git a/docs/docx2md-spec.md b/docs/docx2md-spec.md index 4bcdba1..5ff6fd7 100644 --- a/docs/docx2md-spec.md +++ b/docs/docx2md-spec.md @@ -121,8 +121,10 @@ This is a deliberate output policy, not merely an implementation limitation. - XML parsing style - TypeScript-first source management - single-file web app packaging direction where practical +- `lht-cmn/` based browser UI composition Unless there is a clear `docx`-specific reason to differ, `docx2md` should imitate `miku-xlsx2md` in overall repository structure, implementation style, naming discipline, test style, and browser/runtime separation. +For browser UI, `docx2md` should treat `lht-cmn/` as the primary shared component layer and should prefer `lht-*` components over page-local widget implementations unless there is a clear reason to differ. However, `docx2md` should not inherit spreadsheet-specific behavior. There is no table-region detection problem equivalent to Excel sheet analysis. @@ -301,6 +303,11 @@ This means the final visible output places `bold` outermost when all four styles - external links should become Markdown links when possible - format: `[text](url)` +- document-internal links should become `[text](#anchor)` when the target anchor is known +- when a paragraph owns a bookmark target, the Markdown output may emit a lightweight HTML anchor such as `` immediately before the block +- internal anchor names should be normalized into stable fragment-safe ids +- recommended normalization is: trim, lowercase, collapse whitespace to `-`, replace unsupported punctuation with `-`, and collapse repeated `-` +- unresolved internal links should fall back to plain link text rather than emitting a broken Markdown target - when hyperlink text also has underline formatting, GitHub output does not need to add extra underline markup on top of the link - document-internal links should also be preserved when the target can be resolved safely - a resolved internal link may render as `[text](#anchor)` @@ -409,6 +416,8 @@ Cell-level rules: - a line break inside a table-cell paragraph should render as `
` - multiple paragraphs inside one table cell may be joined using `

` - table-cell text should be trimmed at both ends before final Markdown emission +- when a table-cell paragraph is structurally a list item, the output may preserve it as simplified inline list text such as `- item` or `1. item` +- nested list depth inside table cells may be represented with lightweight indentation rather than full block Markdown structure List-item text should follow the same general normalization policy as ordinary paragraphs unless a later implementation section defines a narrower exception. diff --git a/index-src.html b/index-src.html new file mode 100644 index 0000000..2030f01 --- /dev/null +++ b/index-src.html @@ -0,0 +1,83 @@ + + + + + + miku-docx2md + + + + + + + +
+
+ + Read a local .docx file, preserve supported text structure, and generate GitHub-compatible Markdown / HTML. Images and drawing objects are intentionally out of scope in the first cut. + + +
+

Input

+
+
+ + + + + Unsupported elements are omitted by default. Enable this only when you want Markdown output to include debug-oriented <!-- unsupported: ... --> traces. + + +
+ + + + +
+ +
Loading UI ...
+
Processing stays local in your browser. No data is sent to a server.
+
+ +
+

Summary

+ +
+
+
+ +
+

Markdown

+ +
+ + + + +
+
+ + + + + + + + + + + + diff --git a/index.html b/index.html new file mode 100644 index 0000000..601e066 --- /dev/null +++ b/index.html @@ -0,0 +1,4418 @@ + + + + + + miku-docx2md + + + + + + + + +
+
+ + Read a local .docx file, preserve supported text structure, and generate GitHub-compatible Markdown / HTML. Images and drawing objects are intentionally out of scope in the first cut. + + +
+

Input

+
+
+ + + + + Unsupported elements are omitted by default. Enable this only when you want Markdown output to include debug-oriented <!-- unsupported: ... --> traces. + + +
+ + + + +
+ +
Loading UI ...
+
Processing stays local in your browser. No data is sent to a server.
+
+ +
+

Summary

+ +
+
+
+ +
+

Markdown

+ +
+ + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lht-cmn/FEEDBACK.md b/lht-cmn/FEEDBACK.md new file mode 100755 index 0000000..deb3c6c --- /dev/null +++ b/lht-cmn/FEEDBACK.md @@ -0,0 +1,54 @@ +# lht-cmn Feedback + +## 2026-03-08 `lht-command-block` style contract gap + +- 症状: + - `lht-command-block` をページ側で利用しても、`lht-cmn/css/components.css` だけでは「角丸四角の結果表示ブロック」として視覚的に完成しない + - 実際には `md-code-block` / `md-code` / `md-copy-button` / `md-icon-button.md-copy-button` 相当の見た目をページ側 CSS が別途持っている前提になっている +- 問題: + - `lht-command-block` を共通部品として使っても、利用ページごとに結果表示の見た目が欠けうる + - `lht-cmn` の self-contained 方針とずれている +- 期待: + - `lht-command-block` は `lht-cmn/css/components.css` だけで最低限の完成した見た目になるべき + - 少なくとも以下の visual contract は `lht-cmn` 側に同梱する + - `.md-code-block` + - `.md-code` + - `.md-copy-button` + - `md-icon-button.md-copy-button` + - `md-icon-button.md-copy-button--surface` +- 補足: + - 今回 `docs/prompt/prompt-gen-src.html` では、既存画面に合わせるためページ側へ上記スタイルを追加して回避した + - 根本対応は `lht-cmn` 側で行うべき + +## 2026-03-08 `lht-switch-help` material bundle gap + +- 症状: + - `lht-switch-help` を利用しても、ページ側で `md-switch` が未登録のため fallback 実装に落ちる + - `prompt-gen` では text field は Material 表示なのに switch だけ fallback 表示になる +- 問題: + - `lht-*` を使っても、入力部品ごとに Material / fallback が混在しやすい + - ページ側から見ると `lht-switch-help` の見た目が他の Material 部品と揃わず、利用側で原因が見えにくい +- 期待: + - `lht-switch-help` も `lht-cmn` 側で Material 実装を self-contained に利用できる形に寄せたい + - 少なくとも `md-switch` 用 bundle の vendor / 読み込み導線を `lht-cmn` 側で用意したい + - それが難しい場合でも、README に「switch は fallback 前提になりうる」ことを明記したい +- 補足: + - 現状の `lht-switch-help` は `window.customElements.get("md-switch")` が false のとき fallback DOM を生成する実装になっている + - `lht-cmn/vendor` には `material-web-outlined-text-field.bundle.js` はあるが、`md-switch` 相当の bundle は見当たらない + +## 2026-03-09 `lht-text-field-help` trailing action gap + +- 症状: + - `prompt-gen` で「やりたいこと」入力欄に `×` クリアボタンを付けたかったが、`lht-text-field-help` 自体には trailing action / trailing icon を安全に差し込む契約がない + - 外付け absolute 配置では、Material 側の見た目中心と合いにくく、位置合わせが不安定だった +- 問題: + - 画面ごとに似た「入力クリア」「末尾アイコン」「補助アクション」実装が再発しやすい + - `lht-text-field-help` を使っていても、入力欄内アクションはページ側の局所 CSS に依存しやすい + - fallback 実装と Material 実装で、末尾アクションの見た目や余白が揃いにくい +- 期待: + - `lht-text-field-help` に trailing action slot、または `clearable` のような共通機能を検討したい + - もし汎用化しない場合でも、「入力欄右端に後付けアクションを重ねる時の推奨パターン」を README に明記したい +- 補足: + - 今回は暫定対応として `lht-text-field-help` に `clearable` 属性をローカル追加し、`prompt-gen` ではそれを利用する形へ寄せた + - ただしこれは prompt-gen 都合で先に入れた provisional API なので、`lht-cmn` チーム側では trailing action 全般を扱える正式な契約として見直したい + - 正式方針が固まったら、今回の `clearable` 実装や CSS 調整はレビューのうえ置き換えたい diff --git a/lht-cmn/LICENSE b/lht-cmn/LICENSE new file mode 100755 index 0000000..4bb44d2 --- /dev/null +++ b/lht-cmn/LICENSE @@ -0,0 +1,197 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with the Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/lht-cmn/NOTICE b/lht-cmn/NOTICE new file mode 100755 index 0000000..d1b7fb9 --- /dev/null +++ b/lht-cmn/NOTICE @@ -0,0 +1,11 @@ +Copyright 2026 Toshiki Iga + +This product includes software developed by Toshiki Iga. + +Design direction: +- 本プロジェクトは Material Design 3 の設計原則を参照しています。 + +Third-party component implementation: +- `lht-cmn` の内部実装では、必要に応じて Material Web(`@material/web`)を優先利用します。 +- Material Web のライセンスは Apache License, Version 2.0 です。 +- Source: https://github.com/material-components/material-web diff --git a/lht-cmn/README.md b/lht-cmn/README.md new file mode 100755 index 0000000..e7dad3c --- /dev/null +++ b/lht-cmn/README.md @@ -0,0 +1,408 @@ +# lht-cmn + +`lht-cmn` は `local-html-tools` 全体で共有する UI コンポーネント基盤です。 + +- Version: `v20260308` +- License: Apache License 2.0 (`lht-cmn/LICENSE`) +- Copyright: Toshiki Iga + +## ライセンスと帰属 + +- `lht-cmn` 自体は Apache License 2.0 で配布します。 +- デザイン方針は Material Design 3 の設計原則を参照します。 +- 実装技術として、`lht-cmn` は必要に応じて Material Web(`@material/web`)を優先利用します。 +- Material Web のライセンスは Apache License 2.0 です。 +- 帰属情報の詳細は `lht-cmn/NOTICE` に記載します。 + +## 目的 + +`local-html-tools` では、入力・選択・ヘルプ・コピー・メニューなどの UI を複数ページで繰り返し実装してきました。 +`lht-cmn` はこの重複を減らし、UI を `lht-*` Web Components として共通化するためのレイヤーです。 + +## 基本方針 + +- デザイン基準は Material Design 3 +- 画面側の公開 UI レイヤーは常に `lht-*` とし、`md-*` を直接使わせない +- `lht-*` は self-contained を原則とし、アプリ側へ `md-*` の登録責務を漏らさない +- 実現手段として Material Web を優先利用してよい +- ただし Material 利用の有無に関わらず、`lht-*` は最低保証で壊れないことを優先する +- 内部実装として許可する型は次の 2 つに限定する: + - `md-*` 優先 + fallback + - 完全自前実装 +- 「Material 依存で fallback なし」は原則避け、採る場合は README に明示する +- fallback は Material の完全再現ではなく、公開 API の最低保証に留める + +## メリット + +- 画面ごとの重複実装を削減できる +- 見た目と挙動(必須表示、ヘルプ表示、フォーカス時の挙動)を統一できる +- 変更点を `lht-cmn` に集約でき、保守・レビューがしやすくなる +- 単一HTML生成前提でも、開発時の部品再利用性を維持できる +- 変更点が局所化され、生成AIが誤って別画面を壊す確率が下がる +- UI規約が `lht-*` に集約され、提案が毎回同じ型で出せる +- レビュー時に「画面の見た目差分」より「共通部品の差分」を見ればよくなり、判断が速くなる + +## 運用方針(重要) + +- 画面側(`docs/*-src.html`)は `lht-*` を利用し、`md-*` 直接実装の追加は原則避ける +- `lht-cmn/js/components.js` を共通コンポーネントの正本とする +- `lht-cmn/css/components.css` を実運用スタイルの正本とする +- `lht-cmn/` 配下(特に `js/components.js` / `css/components.css`)の変更は、必ずユーザーの明示許可を得てから実施する +- `md3/` は段階的にリファレンス用途へ縮退し、実運用スタイルは `lht-cmn` に集約する + +## 構成 + +- `lht-cmn/js/components.js` + - 共通 Web Components 定義 +- `lht-cmn/css/components.css` + - 上記コンポーネントの共通スタイル +- `lht-cmn/catalog/index.html` + - 実表示と HTML 利用例を並べて確認するコンポーネントカタログ + +### コンポーネント一覧 + +| コンポーネント | できること | 内部構造(概要) | +|---|---|---| +| `lht-help-tooltip` | `(i)` ヘルプアイコンとツールチップを1タグで配置できる | `md-icon-button` が使える環境ではそれを利用し、未定義時はネイティブ `button` fallback を生成。タグ内HTMLをツールチップ本文へ差し込む | +| `lht-text-field-help` | ラベル付き入力(単一行/複数行)とフォーカス時ヘルプ表示を共通化できる | `md-outlined-text-field` が使える環境ではそれを利用し、未定義時はネイティブ `input` / `textarea` fallback を生成。属性(`field-id`/`label`/`type`/`rows` など)を透過する | +| `lht-select-help` | ラベル付きドロップダウンとヘルプ表示を共通化できる | 内部で `md-outlined-select` を生成し、`` を使用する +3. `lht-select-help` で `