diff --git a/MARKETING_AND_COMMUNITY_OUTREACH.md b/MARKETING_AND_COMMUNITY_OUTREACH.md new file mode 100644 index 00000000..800d41f1 --- /dev/null +++ b/MARKETING_AND_COMMUNITY_OUTREACH.md @@ -0,0 +1,1012 @@ +# Markdown Viewer Marketing And Community Outreach Program + +Prepared by: Chief Product Marketing Officer and specialist research team + +Date: June 7, 2026 + +Product analyzed: Markdown Viewer, version 3.7.3 + +Repository: https://github.com/ThisIs-Developer/Markdown-Viewer + +Live demo: https://markdownviewer.pages.dev/ + +## Research Method + +This deliverable was written after inspecting the local repository, documentation, wiki pages, changelog, web app implementation, service worker, Docker assets, GitHub workflows, and desktop app wrapper. External research was used only for competitor and community positioning. + +Specialist team: + +- Chief Product Marketing Officer: final messaging, claim review, publication approval. +- Agent 1 - Product Research Specialist: feature inventory, workflows, strengths, caveats. +- Agent 2 - Developer Experience Researcher: architecture, build, deployment, technical capabilities. +- Agent 3 - Technical Writer: documentation and release-note accuracy. +- Agent 4 - Community Marketing Specialist: Reddit, Hacker News, GitHub, Dev.to, Product Hunt audience fit. +- Agent 5 - SEO Content Strategist: keyword clusters and search-intent opportunities. +- Agent 6 - Competitor Analyst: comparison against Obsidian, Typora, MarkText, StackEdit, Dillinger, and VS Code Markdown. +- Agent 7 - Storytelling Specialist: user journeys and launch narratives. +- Agent 8 - Developer Advocate: technical-to-practical benefit translation. +- Agent 9 - Content Editor: readability and launch-readiness review. +- Agent 10 - Community Growth Specialist: distribution plan and ongoing community motion. + +Important accuracy note: some docs say the copy button copies rendered HTML, but the current implementation copies raw Markdown. Public-facing copy in this deliverable treats the button as "copy Markdown" unless the implementation changes. + +## Section 1 - Product Analysis + +Markdown Viewer is an open-source, client-side Markdown editor and preview workspace. It is not just a passive viewer and it is not trying to be a full personal knowledge management system. The strongest positioning is: a focused Markdown workbench for people who need to write, inspect, render, export, and share technical Markdown with GitHub-style output, diagrams, math, code, frontmatter, and multiple documents. + +The product runs as a static web application built with HTML, CSS, and vanilla JavaScript. There is no server-side rendering pipeline, no account layer, and no application database. Markdown is rendered in the browser using marked.js, styled with GitHub Markdown CSS, syntax-highlighted through highlight.js, sanitized with DOMPurify, and extended with MathJax, Mermaid, JoyPixels, js-yaml, pako, FileSaver.js, html2canvas, and jsPDF. The web build is also installable as a PWA through `manifest.json` and caches the application shell through `sw.js`. + +The application offers a left/right editor and preview flow with synchronized scrolling, editor-only mode, preview-only mode, and split mode. It also includes a full formatting toolbar, multiple document tabs, local file import, GitHub import, Markdown/HTML/PDF export, share-by-URL, multilingual UI, and an advanced find-and-replace panel. + +The desktop version is powered by Neutralinojs. The desktop folder shares the core web files, prepares desktop resources, injects Neutralino-specific scripts, supports native file dialogs and filesystem read/write, and includes scripts for Windows embedded builds and portable bundles. The root project is Apache-2.0 licensed; the desktop folder also contains the MIT license for Neutralinojs components. + +The repo also has a few metadata and documentation caveats that should be handled transparently. The root license is Apache-2.0, while the desktop README uses MIT wording around the desktop port and Neutralinojs. The package lock in `desktop-app/` still reports version `1.0.0` while the package and Neutralino config report `3.7.3`. The changelog references Playwright E2E coverage, but no committed test suite or test script was found in the inspected files. A "Development Journey" page exists, but no future roadmap file was found. + +The product is built for practical Markdown work: + +- Developers writing READMEs, architecture notes, release notes, runbooks, and AI-generated design docs. +- Technical writers validating GFM, diagrams, math, code, tables, alerts, and exported documents. +- Students and researchers writing math-heavy Markdown with LaTeX and footnotes. +- Documentation teams that need a browser-friendly Markdown previewer without an account workflow. +- Open-source maintainers who want a transparent, self-hostable Markdown tool. +- Productivity users who want Markdown tabs, autosave, export, and sharing without a heavy knowledge-base system. + +The unique product shape is the combination of broad rendering support, multi-document browser workflow, GitHub import, advanced find-and-replace, share links, self-hosting, and desktop packaging. Many Markdown tools do one or two of these well. Markdown Viewer combines them in a small static application with open code and clear data-flow documentation. + +The product's current best story is not "the only Markdown editor you need." That would be too broad and would invite poor comparisons to Obsidian, Typora, VS Code, and StackEdit. The better story is: + +Markdown Viewer is a focused Markdown workspace for technical documents that need to look right before they ship. + +That means: + +- Render AI-generated Markdown with diagrams and math without pasting into a cloud editor. +- Preview GitHub-style docs before committing or publishing them. +- Import multiple Markdown files from a public GitHub repo or folder. +- Keep several documents open in local browser tabs. +- Export a polished standalone HTML file or PDF. +- Share a document through an encoded URL without server storage. +- Run the same tool locally, in Docker, or as a desktop app. + +## Section 2 - Feature Inventory + +### Core Editing And Preview + +- Live split-screen editor and preview. +- Editor-only, split, and preview-only modes. +- Two-way synchronized scrolling in split mode. +- Resizable editor/preview panes. +- Line numbers with wrap-aware layout. +- Fullscreen editing mode. +- Light and dark themes with saved preference. +- Local autosave and session restore through `localStorage`. +- Responsive mobile menu with tabs, view modes, stats, import/export/copy/share/theme/language controls. + +### Markdown Rendering + +- CommonMark and GitHub Flavored Markdown oriented rendering through marked.js. +- Tables. +- Task lists. +- Strikethrough. +- Fenced code blocks. +- Autolinks and extended links. +- Reference-style links and images. +- Inline HTML rendered after sanitization. +- GitHub-style alerts: note, tip, important, warning, caution. +- YAML frontmatter parsing through js-yaml and rendered as a metadata table. +- Footnotes with back references. +- Definition lists. +- Superscript. +- Subscript. +- Highlight marks. +- Raw HTML examples such as `kbd`, `abbr`, `mark`, `u`, `sub`, and `sup` are represented in the default sample and docs where allowed by sanitization. + +### Code, Math, And Diagrams + +- Syntax highlighting for 190+ languages through highlight.js, according to README/docs. +- Inline and display LaTeX math through MathJax. +- Custom display math preservation so multiline `$$...$$` blocks survive Markdown parsing. +- Mermaid diagram rendering inside fenced `mermaid` blocks. +- Mermaid diagram modal with zoom, pan, reset, copy image, PNG download, and SVG download. +- Mermaid toolbar on rendered diagrams. +- Export pipeline attempts to preserve MathJax and Mermaid in HTML/PDF outputs. + +### Multi-Document Workflow + +- Multiple document tabs. +- New tab. +- Rename tab. +- Duplicate tab. +- Delete tab. +- Reset all tabs. +- Drag-and-drop tab reordering. +- Scroll position saved per tab. +- View mode saved per tab. +- Active tab persistence. +- 20-tab limit. +- Mobile tab list and mobile new/reset controls. + +### Import + +- Local `.md` and `.markdown` file import. +- Drag-and-drop Markdown import with full-window drop overlay. +- 10 MB local file import cap. +- Binary-file rejection guard. +- Public GitHub import from repository, folder/tree, blob/file, or raw URL. +- GitHub import uses `api.github.com` and `raw.githubusercontent.com`. +- GitHub import lists Markdown files recursively. +- Selectable file tree with multi-select and select-all. +- First 30 Markdown files shown when a large GitHub path contains more files. +- Imported GitHub files open as tabs. + +### Export And Sharing + +- Export raw Markdown as `.md`. +- Export standalone HTML with GitHub-style CSS, syntax-highlight styles, MathJax, Mermaid, alerts, footnotes, and frontmatter table styling. +- Export PDF using jsPDF and html2canvas. +- PDF export includes progress UX, cancellation logic, page-break analysis, graphic scaling, and handling for math/diagram content. +- Browser print-to-PDF is recommended in docs for highest fidelity when the built-in PDF export differs from preview. +- Share modal with view-only and edit modes. +- Share links compress Markdown with pako and encode content into the URL hash. +- Share links do not upload content to a server. +- Share URL length cap is 32,000 characters. + +### Formatting Toolbar + +- Undo and redo with custom in-memory document history. +- Clear active document. +- Bold, italic, strikethrough, blockquote. +- Title case, uppercase, lowercase transforms. +- Align left, center, right. +- LTR/RTL direction toggle scoped to editor and preview. +- H1 through H6 insertion. +- Ordered and unordered list helpers. +- Horizontal rule. +- Link insertion modal. +- Reference insertion modal. +- Image insertion from URL or local file. +- Inline code, code block, terminal block. +- Table insertion modal with row/column controls. +- Date/time insertion. +- Emoji shortcode picker. +- Symbols and HTML entity picker. +- Markdown alert picker. +- Help and About modals. + +### Advanced Find And Replace + +- Floating find-and-replace panel. +- Docked mode toggle. +- Draggable floating position. +- Reset position. +- Match case. +- Whole word. +- Regular expression mode. +- Capture group replacement. +- Named capture group replacement. +- Preserve case. +- Wrap around. +- Find in selection. +- Search history. +- Replace current match. +- Replace all. +- Optional diff preview before replace-all. +- Scope filters for entire document, headings, code blocks, LaTeX blocks, Mermaid blocks, and plain text. +- Block syntax validation for LaTeX and Mermaid replacements. + +### Performance And Reliability + +- Large-document scheduling threshold at 15,000 characters. +- Preview-worker rendering threshold at 50,000 characters, with segmented rendering requiring at least eight blocks. +- Debounced rendering. +- Skeleton loaders for editor, preview, emoji grid, and GitHub import tree. +- Lazy-loaded heavy libraries: Mermaid, MathJax, JoyPixels, pako, html2canvas, jsPDF. +- Content-aware render bypass through last-rendered-content checks. +- Preview Web Worker path with segmented rendering and main-thread fallback. +- DOM patching and reusable preview blocks for segmented rendering. +- RequestAnimationFrame usage for scroll synchronization and layout work. +- Editor line-height/cache optimizations with a 5,000-entry line cache. +- Service worker caching for local shell assets and lazy CDN asset caching. + +### Current Documentation And Metadata Caveats + +- Current code copies raw Markdown through the copy button, despite some docs describing rendered-HTML copy. +- Root license file is Apache-2.0; desktop README wording should be clarified because it also mentions MIT for the desktop version and Neutralinojs. +- `desktop-app/package-lock.json` reports version `1.0.0`, while `desktop-app/package.json` and `neutralino.config.json` report `3.7.3`. +- Changelog mentions Playwright E2E coverage, but no committed test suite or `test` npm script was found. +- No future roadmap file was found beyond release history and the Development Journey page. + +### Security, Privacy, And Data Flow + +- Client-side rendering and local browser storage. +- DOMPurify sanitization for preview HTML. +- Root Dockerfile includes security headers such as X-Frame-Options, X-Content-Type-Options, Referrer-Policy, and a Content Security Policy. +- CDN script/style tags include SRI integrity attributes. +- Desktop prepare script downloads remote dependencies and validates SHA-384 integrity before bundling. +- No analytics scripts or advertising pixels found in docs or inspected source. +- Network dependencies remain for CDN libraries unless cached or self-hosted, and GitHub import contacts public GitHub endpoints. +- Share links encode content in the URL hash. + +### Deployment And Distribution + +- Live static web app. +- Self-hostable from any static server. +- Docker image served by Nginx Alpine. +- Docker Compose support. +- GHCR container publishing workflow for main branch and commit/branch tags. +- Desktop build workflow triggered by `desktop-v*` tags. +- Desktop release assets include Windows executable, portable bundle, source archive, and SHA256 checksums. +- PWA manifest for installable browser usage. + +### Documentation And Community + +- README with features, quick start, screenshots, docs links, and contribution links. +- Wiki pages for Home, Features, Usage Guide, Installation, Configuration, Docker Deployment, Desktop App, Markdown Reference, FAQ, Contributing, and Development Journey. +- Changelog with detailed release history through v3.7.3. +- Contributing guide with bug reporting, feature requests, setup, code style, Conventional Commits, PR process, and project structure. + +## Section 3 - Competitive Advantage Analysis + +This section compares Markdown Viewer against the requested competitors using verified competitor sources as of June 7, 2026. It avoids claiming broad superiority. + +### Obsidian + +Obsidian is a private notes and knowledge-base product built around local vaults, backlinks, graph views, plugins, Sync, and Publish. Its own docs describe a vault as a folder on the local filesystem, and its homepage emphasizes private local notes, plugins, links, graph, Sync, and Publish. + +Markdown Viewer should not compete head-to-head with Obsidian as a knowledge-management system. It does not have backlinks, graph search, canvas, plugins, vaults, or paid sync. Its advantage is focus. Users can open, render, edit, export, or share a Markdown document without adopting a vault-based note system. + +Best positioning: "A focused Markdown viewer/editor for technical docs, READMEs, AI-generated Markdown, and export workflows when you do not need a full PKM system." + +### Typora + +Typora is a polished commercial Markdown editor with a single-pane live-rendering writing experience, strong export options, math, and diagrams. Typora's docs describe PDF/HTML/image export and additional formats through Pandoc, plus diagram support after enabling diagram features. + +Markdown Viewer should not claim to be more polished than Typora. The differentiator is open-source transparency, browser availability, static self-hosting, GitHub import, encoded share links, and a split source/preview model for users who want raw Markdown visible at all times. + +Best positioning: "For users who prefer source-visible editing, open code, browser access, and self-hosting over a paid single-pane desktop editor." + +### MarkText + +MarkText is a free open-source Markdown editor available for Linux, macOS, and Windows. Its public repo and site position it around real-time preview, clean writing, CommonMark/GFM support, math, frontmatter, emojis, and diagrams. + +MarkText is the closest open-source editor competitor. Markdown Viewer should avoid "open-source Markdown editor" as the only hook. The practical wedge is a static browser app with Docker/PWA deployment, GitHub import, share links, advanced find-and-replace scopes, and a web/desktop hybrid that is easier to self-host. + +Best positioning: "A browser-first open Markdown workbench with deployment flexibility and technical-doc rendering depth." + +### StackEdit + +StackEdit is an in-browser Markdown editor with syntax highlighting, WYSIWYG controls, scroll sync, sync with Google Drive/Dropbox/GitHub, publishing to Blogger/WordPress/Zendesk, collaboration, comments, offline access, GFM/CommonMark, LaTeX, diagrams, scores, and emojis. + +StackEdit is stronger for cloud-connected web writing and collaboration. Markdown Viewer is stronger for a simpler no-account workflow, public GitHub import, local tabbed editing, source-visible preview, self-hosted static deployment, and privacy-forward handling. + +Best positioning: "A simpler, local-first-feeling Markdown workspace for docs and files, not a cloud publishing workspace." + +### Dillinger + +Dillinger offers a Monaco-based browser editor, live preview, cloud sync across multiple providers, Markdown/HTML/PDF export, drag/drop, dark mode, browser persistence, and no-account use. + +Dillinger has a strong no-friction web-editor story and cloud integrations. Markdown Viewer should differentiate on technical rendering details and open-source/self-hosting clarity: Mermaid toolbar/export, MathJax, frontmatter, GitHub alerts, advanced replace scopes, PWA/service worker, Docker, desktop wrapper, and transparent data flow. + +Best positioning: "A technical-document Markdown workbench with diagrams, math, GitHub import, and self-hostable open-source packaging." + +### VS Code Markdown + +VS Code has strong built-in Markdown support: preview, side-by-side preview, scroll sync, outline, snippets, path completions, Mermaid, math, preview security settings, and an extension ecosystem. + +VS Code is excellent for developers already inside an IDE. Markdown Viewer should not claim to replace it. The advantage is dedicated context. It is easier for non-IDE users, students, reviewers, and docs teams to open a browser or desktop viewer, preview a document, import from GitHub, export/share, and avoid IDE overhead. + +Best positioning: "A dedicated Markdown surface for reviewing, rendering, and exporting docs outside an IDE." + +### Differentiators That Are Defensible + +- Static, self-hostable web app plus Docker plus desktop wrapper. +- Open-source Apache-2.0 project. +- Client-side rendering with documented data flow and no app server. +- Strong technical-document rendering: GFM, code, MathJax, Mermaid, frontmatter, alerts, footnotes, definition lists, sup/sub/highlights. +- Mermaid toolbar with zoom, pan, copy image, PNG download, and SVG download. +- Multi-document tabs in the browser with persistence and reorder. +- Public GitHub import with file-tree selection and multi-file tab import. +- Share links with view/edit modes and no server-side document storage. +- Advanced scoped find-and-replace with diff preview. +- Lazy-loaded heavy dependencies and large-document performance engineering. +- Built-in international UI language support and SEO/hreflang groundwork. + +### Positioning Guardrails + +Use: + +- "GitHub-style" rather than "identical to GitHub." +- "Client-side" rather than "zero network requests." +- "No server-side document storage" rather than "fully offline in all modes." +- "Open source" and "Apache-2.0 licensed" rather than "free forever" unless the maintainer confirms product policy. +- "Markdown/HTML/PDF export" rather than "all document formats." +- "Copy Markdown" rather than "copy rendered HTML" for current version. + +Avoid: + +- "Obsidian alternative" as a primary claim. +- "Typora killer." +- "Best Markdown editor." +- "Fully GitHub-compatible." +- "No network calls." +- "Collaboration," "cloud sync," "DOCX export," "AI assistant," "token counter," or "plugin ecosystem." + +## Section 4 - Target Audience Analysis + +### Developers + +Developers need README previews, architecture docs, changelogs, ADRs, release notes, runbooks, prompt files, GitHub-flavored syntax, code highlighting, Mermaid, and quick export. Markdown Viewer gives them a source-visible workspace with GitHub import and a dedicated preview outside the IDE. + +Messaging: "Preview technical Markdown before it lands in a repo." + +### Technical Writers And Documentation Teams + +Docs teams care about consistency, diagrams, math, code, alerts, frontmatter, export, and sharing drafts. Markdown Viewer is useful for docs-as-code review, GitHub docs previews, handoff HTML/PDF, and validating complex Markdown. + +Messaging: "A browser-based review bench for docs-as-code." + +### Students And Researchers + +Students and researchers need formulas, notes, citations/references, tables, exports, and readable preview. MathJax, footnotes, frontmatter, tabs, and PDF export are the strongest features. + +Messaging: "Write Markdown notes with equations, diagrams, and exportable structure." + +### Open-Source Maintainers + +Maintainers can use Markdown Viewer to review READMEs, write changelogs, import public GitHub docs, and share previews. They also care about transparent code, licensing, contribution docs, and self-hosting. + +Messaging: "Open-source Markdown tooling for open-source docs." + +### Productivity Users + +These users want something simpler than an IDE or PKM app. They value no sign-up, tabs, autosave, dark mode, copy/share/export, and mobile access. + +Messaging: "Open a Markdown document, make it readable, and move on." + +### AI-Era Markdown Users + +AI-generated output increasingly includes Markdown tables, code, Mermaid, and LaTeX. Markdown Viewer can become a practical place to paste generated docs, inspect structure, export, or share in view-only mode. + +Messaging: "A clean way to read and fix Markdown generated by AI tools." + +## Section 5 - Long-form Feature Showcase Article + +# Markdown Viewer: A Focused Workspace For Technical Markdown + +Markdown is simple until your document stops being simple. + +A short note renders almost anywhere. A README with headings and links is easy. But the moment you add fenced code blocks, task lists, diagrams, equations, GitHub-style alerts, frontmatter, references, footnotes, and export requirements, the familiar "just write Markdown" workflow can turn into a messy loop of saving, previewing, refreshing, testing, exporting, and hoping the final version still looks right. + +Markdown Viewer is built for that moment. + +It is an open-source Markdown editor and preview workspace that runs in the browser, can be self-hosted as a static app, ships with Docker support, and also has a Neutralino-powered desktop port. It is designed for people who work with technical Markdown: developers, students, documentation teams, open-source maintainers, researchers, and anyone who wants to inspect a Markdown document without adopting a full IDE or knowledge-base system. + +The core experience is straightforward. You write Markdown in the editor pane, and the rendered preview updates beside it. You can keep the split view, switch to editor-only mode for drafting, or switch to preview-only mode when you want to read. The panes can be resized, synchronized scrolling can be toggled, and the app tracks document statistics such as reading time, word count, and character count. + +That simple surface hides a surprisingly capable Markdown engine. + +## GitHub-Style Rendering For Real Technical Docs + +Markdown Viewer supports GitHub Flavored Markdown patterns such as tables, task lists, strikethrough, fenced code blocks, autolinks, and extended links. Code blocks are syntax-highlighted with highlight.js, with documentation noting support for more than 190 languages. The preview is styled with GitHub Markdown CSS, giving docs a familiar GitHub-like reading shape. + +That matters because many Markdown documents are written for GitHub, even when they are not read only on GitHub. READMEs, issue templates, docs pages, changelogs, project specs, and release notes often need to look good before they are committed. Markdown Viewer gives you a dedicated place to review that output. + +It also supports GitHub-style alerts: + +```markdown +> [!NOTE] +> This is useful context. +``` + +The app transforms those alert blocks into styled callouts for note, tip, important, warning, and caution messages. For documentation teams, that means the same kind of visual hierarchy used in modern GitHub docs can be drafted and reviewed before publishing. + +## Diagrams And Math Without Leaving Markdown + +Technical docs rarely stay text-only. Architecture notes need diagrams. Academic notes need equations. Product specs need flowcharts. Engineering plans often include sequence diagrams or decision paths. + +Markdown Viewer supports Mermaid diagrams directly inside fenced `mermaid` code blocks. Flowcharts, sequence diagrams, class diagrams, state diagrams, entity-relationship diagrams, Gantt charts, pie charts, mindmaps, and other Mermaid-supported diagram types can render in the preview. + +The diagram experience goes beyond rendering. Rendered Mermaid diagrams get a toolbar, and clicking into the diagram opens a larger modal view. From there you can zoom, pan, reset the view, copy the diagram as an image, download a PNG, or download an SVG. That makes Markdown Viewer especially useful for AI-generated or docs-as-code diagrams, where the source may be text but the stakeholder needs a visual. + +Math support is handled through MathJax. Inline and display equations can be written with common LaTeX delimiters. The implementation includes custom handling for multiline display math blocks, preserving `$$...$$` sections so they survive Markdown parsing before MathJax typesets them. + +For students, researchers, teachers, and technical writers, this is a key advantage. You can keep formulas inside the Markdown source, preview them in context, and export the final rendered document. + +## More Than Basic Markdown + +The application also handles several Markdown extensions and rich-document patterns that matter in real writing: + +- YAML frontmatter is parsed and rendered as a metadata table above the document. +- Footnotes are rendered with numbered references and back links. +- Definition lists can be represented. +- Superscript and subscript are supported. +- Highlight marks can be rendered. +- Reference-style links and images are enhanced. +- Inline HTML is allowed through a sanitized rendering pipeline. + +This gives Markdown Viewer a useful middle ground. It is still a source-visible Markdown editor, not a WYSIWYG document processor, but it understands enough document structure to support serious notes and documentation. + +## A Multi-Document Browser Workspace + +Most browser Markdown tools are single-document scratchpads. Markdown Viewer adds tabs. + +You can create a new tab, rename it, duplicate it, delete it, and reorder tabs by dragging. Tab state persists in localStorage, including active tab and document content. Each tab can also preserve view mode and scroll position. The current implementation caps the workspace at 20 tabs, which is a sensible guardrail for a browser-based document surface. + +That tabbed workflow changes how the app feels. Instead of being a one-off paste box, it becomes a small Markdown workbench. A technical writer can keep a README, changelog, install guide, and FAQ open together. A developer can compare an ADR, a runbook, and a Mermaid-heavy architecture note. A student can keep several topic notes open while reviewing formulas. + +## Import From Files Or GitHub + +Markdown Viewer supports local `.md` and `.markdown` files through file picker import and drag-and-drop. The local file import path includes a 10 MB cap and a binary-file guard, which helps keep the app responsive and avoids accidentally loading the wrong file type. + +The GitHub import workflow is one of the product's strongest differentiators. You can paste a public GitHub repository, folder, file, or raw URL. The app uses GitHub's public API and raw content URLs to discover Markdown files, render a selectable file tree, and import selected files into tabs. When a path contains many Markdown files, the app shows the first 30, which keeps the UI manageable. + +That makes the tool useful for open-source exploration. Instead of cloning a repo or opening a full IDE, you can import public Markdown docs, inspect them in a GitHub-style preview, and keep several files open. + +## Export And Share + +Markdown Viewer supports export to Markdown, standalone HTML, and PDF. + +Markdown export saves the raw source. HTML export produces a full document with styles and rendering support so the output can be opened in a browser. The HTML export pipeline includes frontmatter tables, reference links, GitHub alerts, footnotes, syntax-highlight styles, MathJax, Mermaid, and light/dark theme handling. PDF export uses jsPDF and html2canvas, with progress feedback, page-break analysis, and graphic scaling for complex elements. + +The docs are honest about PDF limitations: for the highest fidelity, browser print-to-PDF may produce better results for some complex layouts. That transparency should stay. Markdown-to-PDF is always a hard problem when code blocks, diagrams, math, and long pages meet browser layout constraints. + +Sharing works differently from a cloud editor. Markdown Viewer compresses the document with pako, encodes it into the URL hash, and lets you choose view-only or edit mode. In view-only mode, recipients open the document in preview mode. In edit mode, they open it in split editor and preview mode. The content is embedded in the link rather than uploaded to a server. The current URL limit is 32,000 characters, so this is best for short and medium documents, not giant manuals. + +## Advanced Find And Replace + +Find and replace is often an afterthought in Markdown tools. Here it is a real feature. + +The panel supports regex, match case, whole word, selection-only search, preserve case, wrap around, search history, and capture-group replacements. It can float, dock, reset its position, and show a diff preview before replace-all. + +The standout feature is scoped search. You can search the whole document, headings only, code blocks only, LaTeX blocks only, Mermaid blocks only, or plain text only. That is useful when you need to rename a diagram node, clean up headings, update code snippets, or avoid accidentally changing text inside math blocks. + +This is the kind of power-user feature that gives the app a sharper identity. Markdown Viewer is not just a preview pane. It helps people edit technical Markdown safely. + +## Built For Local And Self-Hosted Workflows + +Markdown Viewer is client-side. The app processes Markdown in the browser and stores document state and preferences in localStorage. There is no account system and no app server storing documents. The docs also state that there are no analytics scripts, advertising pixels, or tracking beacons. + +There are still network dependencies to understand. The web build loads third-party libraries from public CDNs unless they are cached or self-hosted. GitHub import contacts public GitHub endpoints. Share links put content in the URL hash. The project is transparent about those flows, which is exactly how a privacy-conscious tool should behave. + +For teams or individuals who want control, the app can be served from any static web server, run through Docker, deployed with Docker Compose, installed as a PWA, or built as a desktop app. The Dockerfile serves static assets through Nginx Alpine and includes gzip and security headers. GitHub workflows build and publish container images and desktop release assets. + +## Who Should Try It? + +Try Markdown Viewer if you: + +- Write READMEs, release notes, ADRs, runbooks, or docs-as-code. +- Need Mermaid and LaTeX preview without a full IDE. +- Want to preview GitHub-style Markdown before publishing. +- Need a browser-based Markdown workspace with multiple tabs. +- Want to import public GitHub Markdown files quickly. +- Prefer open-source tools with visible data flow. +- Need export to Markdown, HTML, or PDF. +- Often paste AI-generated Markdown and need to inspect diagrams, tables, code, and formulas. + +It may not be the right tool if you need cloud collaboration, backlinks, graph views, plugin ecosystems, DOCX export, live shared editing, or full WYSIWYG editing. That is not a weakness. It is scope clarity. + +Markdown Viewer works best as a focused technical Markdown workspace. It gives you the source, the preview, the rendering depth, the import/export paths, and the local-first-feeling workflow without asking you to move your writing life into a new platform. + +For a tool called Markdown Viewer, it does quite a bit. But the best thing about it is that the extra capability stays attached to a simple job: open Markdown, make it readable, edit it carefully, and ship it with confidence. + +## Section 6 - Technical Deep-Dive Article + +# Inside Markdown Viewer: A Static Markdown Workbench With Serious Rendering Depth + +Markdown Viewer is built around a simple architectural idea: keep the application static, keep document processing client-side, and use focused browser technologies to support increasingly complex Markdown workflows. + +The result is a compact but capable application. The root web app is plain HTML, CSS, and JavaScript. There is no React build, no backend service, no database, and no server-side renderer. The application shell consists primarily of `index.html`, `styles.css`, `script.js`, `preview-worker.js`, `sw.js`, `manifest.json`, assets, Docker files, and wiki documentation. The desktop port lives in `desktop-app/` and shares the root web app files through a preparation step. + +That choice shapes the product. Markdown Viewer is easy to host, easy to inspect, and relatively easy for contributors to understand. It also means the implementation has to be thoughtful about performance, sanitization, third-party dependency loading, and browser storage. + +## Rendering Pipeline + +At the center of the app is marked.js. The app configures a custom renderer and several extensions around marked so it can support more than basic Markdown. + +Standard rendering covers common Markdown and GFM-style needs such as headings, lists, task lists, tables, strikethrough, autolinks, and fenced code blocks. Code blocks are routed through highlight.js, which applies syntax highlighting for recognized languages and falls back to plaintext when a language is not available. + +Mermaid support is handled by intercepting fenced code blocks tagged `mermaid`. Instead of rendering those as normal code blocks, the renderer wraps them in a Mermaid container with a unique ID and stores the original source in an encoded data attribute. After the Markdown is inserted into the preview, post-processing initializes Mermaid and attaches diagram controls. + +Math support is handled with MathJax. The app defines a display-math extension that preserves `$$...$$` blocks as atomic units before marked.js strips or transforms the Markdown. After rendering, MathJax typesets matching targets in the preview. + +The custom Markdown layer also supports: + +- Footnote definitions and references. +- Definition lists. +- Superscript and subscript. +- Highlight marks. +- YAML frontmatter parsing with js-yaml. +- GitHub-style alerts transformed after blockquote rendering. +- Reference-link styling and preview enhancement. + +This architecture is pragmatic. It keeps marked.js as the base parser while adding targeted behavior for the Markdown constructs that matter most in technical docs. + +## Sanitization And Trust Boundaries + +Because Markdown can contain HTML, preview rendering needs a security boundary. Markdown Viewer uses DOMPurify to sanitize generated HTML before inserting it into the preview. The app also throws a clear error if DOMPurify is missing, rather than silently rendering unsafe HTML. + +The exported HTML path also sanitizes content, then adds back the specific tags and attributes needed for MathJax, Mermaid, task-list checkboxes, alerts, and styled output. This is important: export should not bypass the safety assumptions of the live preview. + +The Docker deployment adds another layer of defense. The Dockerfile serves static assets with Nginx Alpine and sets headers such as X-Frame-Options, X-Content-Type-Options, Referrer-Policy, and a Content Security Policy that limits script, style, image, font, and connection sources. The web app itself uses CDN script/style tags with SRI integrity attributes. + +The desktop preparation script goes further. It copies shared web assets into desktop resources, downloads remote CDN dependencies locally, and validates SHA-384 integrity hashes before bundling them. That gives the desktop build a path toward offline execution while preserving dependency integrity checks. + +## Dependency Loading Strategy + +Not every document needs Mermaid, MathJax, emoji rendering, PDF export, or pako compression. Markdown Viewer uses lazy loading for heavy libraries such as Mermaid, MathJax, JoyPixels, pako, html2canvas, and jsPDF. Core libraries like marked, highlight.js, DOMPurify, FileSaver.js, js-yaml, Bootstrap, and GitHub Markdown CSS are loaded in the main page. + +This matters for startup cost. A simple README should not require the full diagram, math, emoji, compression, and PDF stack before the user can type. The changelog documents a performance shift from eager loading to asynchronous dependency loading, and the code reflects that strategy. + +The service worker complements this approach. It precaches critical local assets for offline startup and lazily caches CDN resources when they are requested. Local application files use a stale-while-revalidate style strategy, while CDN assets use a cache-first path once available. + +The important caveat is that "offline" depends on context. The static app can cache assets, and the desktop preparation script can bundle dependencies, but the web build still references public CDNs unless those assets have already been cached or self-hosted. GitHub import naturally requires network access to public GitHub endpoints. + +## Large-Document Rendering + +The current version includes multiple optimizations for large Markdown files. The code defines a 15,000-character threshold for large-document scheduling delays and a separate 50,000-character threshold for preview-worker rendering. This lets the app defer expensive editor/preview work for medium-large inputs, then use segmented worker rendering for much larger documents when the document structure is safe for that path. + +The preview pipeline includes: + +- Skeleton preview loaders. +- Deferred preview work. +- Last-rendered-content checks to skip unnecessary renders. +- Preview Web Worker rendering where safe, above the worker threshold. +- Segmented Markdown rendering. +- Reusable preview-block cache keys. +- DOM patching rather than full replacement when possible. +- Main-thread fallback when worker rendering fails. + +The preview worker imports marked and highlight.js, configures compatible extensions, splits rendering into blocks, and posts segmented results back to the main thread. The main thread can then build sanitized preview sections with `content-visibility: auto`, helping the browser avoid unnecessary rendering work for offscreen blocks. + +The app also optimizes editor overlays and line numbers. Wrapped line heights are cached in memory, with a 5,000-entry line-cache limit. Geometry and scroll state are cached to reduce repeated layout reads. Scroll synchronization uses requestAnimationFrame, which aligns updates with browser painting rather than forcing synchronous work. + +These details matter in a product story. The app is not just "feature-rich"; it has been engineered around the specific pain of large Markdown documents containing code, diagrams, and rendered blocks. + +## Multi-Document State + +The tab system is implemented in the browser using localStorage. Tabs store content, title, view mode, scroll position, and active state. Users can create, rename, duplicate, delete, reset, and drag-reorder tabs. There is a hard cap of 20 tabs to prevent uncontrolled local state growth. + +The app also maintains custom in-memory undo/redo stacks per tab. Programmatic toolbar actions push history states, and normal typing is grouped so undo/redo behaves more predictably than relying only on native textarea behavior. + +This is a good example of the product's design philosophy: it keeps the primitive editor surface simple, but builds workflow support around it. + +## GitHub Import + +The GitHub import flow parses GitHub URLs for repositories, folders, files, and raw content. It uses public GitHub API calls to resolve default branches and recursively list Markdown files. It also builds raw GitHub URLs for selected files and fetches their content. + +The UI presents a file tree with multi-select and select-all behavior. Imports open as tabs, which makes the feature useful for docs review. A user can paste a public repo URL, select several docs files, and review them in one workspace without cloning the repository. + +The feature has practical limits: only `.md` and `.markdown` paths are accepted, and the UI shows the first 30 Markdown files in large results. Those limits are worth mentioning because they clarify the intended workflow. + +## Export Architecture + +Markdown export is straightforward: save the raw source as a `.md` file. + +HTML export is more involved. The app parses frontmatter, renders Markdown, sanitizes HTML, enhances reference links and alerts, chooses light or dark GitHub Markdown CSS, includes syntax-highlight styles, defines MathJax configuration, loads Mermaid, includes footnote styles, and wraps the output in a standalone HTML document. + +PDF export is the most complex path. It relies on html2canvas and jsPDF, but the code adds progress state, abort handling, frame waits, MathJax detection, canvas scale selection, element-position analysis, page-boundary calculation, split-element detection, page-break insertion, and oversized graphic scaling. This is the right kind of complexity for a browser PDF export: the browser still does layout, but the app tries to reduce the worst cases. + +The docs properly warn that browser print-to-PDF can be better for complex layouts. That caveat should remain in marketing copy. Honest limitations build trust. + +## Desktop Port + +The desktop app uses Neutralinojs, not Electron. It shares `script.js`, `styles.css`, `preview-worker.js`, and assets with the web app. The `prepare.js` script copies those files into `desktop-app/resources/`, rewrites paths, strips web-only SEO metadata for desktop resources, downloads CDN dependencies, verifies integrity, and injects Neutralino scripts. + +The runtime integration layer handles: + +- Native tray menu. +- Window close confirmation. +- Native open/save dialogs. +- Local file reads and writes. +- Command-line file loading through Neutralino globals. + +The Neutralino config uses a restricted native allowlist: app exit, open/save/message boxes, tray setup, and filesystem read/write. That is a concrete security and maintainability point for developer audiences. + +## Deployment + +The app can be hosted as static files, run through Python's simple HTTP server, served with Node's `serve`, deployed with Docker, or launched as a desktop app. The Docker image copies only the necessary static web files into Nginx, configures gzip, static caching, and security headers, and exposes port 80. Docker Compose maps host port 8080 to the container. + +GitHub Actions workflows build and publish Docker images on main branch pushes and build desktop release assets when tags matching `desktop-v*` are pushed. The desktop workflow stages release assets and writes SHA256 checksums. + +For contributors, this is approachable. The web app has no build step. The desktop app requires Node.js and uses npm scripts for setup, dev, and build. The contributing guide asks contributors to use vanilla JavaScript, semantic HTML, CSS custom properties, accessibility attributes, and Conventional Commits. + +## The Engineering Takeaway + +Markdown Viewer is technically interesting because it takes a static-app constraint seriously. It does not solve Markdown by moving work to a backend. It solves it with careful browser-side parsing, sanitization, lazy dependency loading, worker-assisted rendering, state persistence, export pipelines, and self-hostable packaging. + +That gives it a useful developer story: a transparent, inspectable Markdown workbench that can be forked, self-hosted, containerized, or wrapped for desktop use without becoming a large application platform. + +## Section 7 - Community Launch Article + +# Show HN / Reddit / Dev.to Draft: I built an open-source Markdown workspace for technical docs with Mermaid, LaTeX, GitHub import, and export + +I work with a lot of Markdown that is no longer "just Markdown": READMEs, architecture notes, AI-generated design docs, release notes, runbooks, diagrams, equations, and docs-as-code files. + +I wanted a focused place to open or paste a Markdown document, keep the source visible, preview it with GitHub-style rendering, and export or share it without creating an account or moving the document into a larger notes system. + +That is the idea behind Markdown Viewer: + +https://github.com/ThisIs-Developer/Markdown-Viewer + +Markdown Viewer is an open-source, client-side Markdown editor and preview workspace. It runs in the browser as a static app, can be self-hosted, has Docker support, and includes a Neutralino-powered desktop port. + +What it supports: + +- Live split editor and preview with sync scrolling. +- GitHub Flavored Markdown style rendering. +- Syntax highlighting for code blocks. +- MathJax for inline and block LaTeX. +- Mermaid diagrams with zoom, pan, copy image, PNG export, and SVG export. +- YAML frontmatter rendered as a metadata table. +- GitHub-style alerts. +- Footnotes, definition lists, superscript, subscript, and highlights. +- Multi-document tabs with rename, duplicate, delete, drag reorder, and saved state. +- Local `.md` / `.markdown` import and drag/drop. +- Public GitHub import from repo, folder, file, or raw URL, with multi-file selection. +- Export as Markdown, standalone HTML, or PDF. +- Share links that encode the document into the URL hash, with view-only and edit modes. +- Advanced find-and-replace with regex, capture groups, preserve case, scope filters, and diff preview. +- Responsive mobile menu and multilingual UI. + +The app processes Markdown in the browser. There is no application server storing documents. The web build does load third-party libraries from CDNs unless cached or self-hosted, and GitHub import uses public GitHub endpoints. The data-flow docs are intentionally explicit about that. + +The feature I personally find most useful is GitHub import plus tabs. You can paste a public repo or docs folder URL, select Markdown files, and open them as separate tabs without cloning the repo. The second most useful feature is the scoped find-and-replace: updating only headings, code blocks, Mermaid blocks, LaTeX blocks, or plain text is handy when editing technical documents. + +This is not meant to replace Obsidian, Typora, or VS Code. Obsidian is a knowledge base. Typora is a polished single-pane writing app. VS Code is an IDE. Markdown Viewer is narrower: a dedicated Markdown surface for previewing, editing, exporting, and sharing technical Markdown. + +I would love feedback on: + +- Which Markdown extensions matter most for real docs workflows? +- Does the GitHub import flow match how people review docs? +- Are the PDF/HTML export paths useful enough, or should browser print/export be emphasized more? +- What should be improved for Mermaid-heavy and AI-generated Markdown? + +Repo: https://github.com/ThisIs-Developer/Markdown-Viewer + +Live demo: https://markdownviewer.pages.dev/ + +## Section 8 - Product Hunt Style Launch Post + +### Product Name + +Markdown Viewer + +### Tagline + +GitHub-style Markdown preview, diagrams, math, tabs, export, and sharing. + +### Short Description + +Markdown Viewer is an open-source, client-side Markdown workspace for technical documents. Write or import Markdown, preview it with GitHub-style rendering, render Mermaid and LaTeX, manage multiple tabs, export to Markdown/HTML/PDF, and share documents through encoded view/edit links. + +### Maker Comment + +Hi Product Hunt, + +Markdown Viewer started from a practical problem: Markdown docs are getting more complex. READMEs, architecture notes, AI-generated specs, docs-as-code pages, and study notes often include code blocks, Mermaid diagrams, LaTeX equations, frontmatter, tables, alerts, and export requirements. + +I wanted a focused tool for opening, previewing, editing, and exporting that kind of Markdown without needing a full IDE, a cloud workspace, or a knowledge-base system. + +Markdown Viewer is open source and runs client-side. It supports live split preview, multi-document tabs, GitHub import, Mermaid diagrams with image/SVG export, MathJax, syntax highlighting, frontmatter tables, GitHub-style alerts, advanced find-and-replace, Markdown/HTML/PDF export, and share links with view-only or edit mode. + +It can run from the hosted web app, a static server, Docker, or the Neutralino desktop port. + +I would especially love feedback from developers, documentation teams, students, and open-source maintainers who work with Markdown every day. + +### Product Hunt Gallery Ideas + +1. Split editor and preview showing README-style Markdown with code, table, alert, and task list. +2. Mermaid diagram modal with zoom, pan, PNG, SVG, and copy controls. +3. GitHub import modal with a repo/folder file tree and multi-select. +4. Advanced find-and-replace panel with Mermaid or LaTeX scope selected. +5. Export/share modal showing Markdown, HTML, PDF, and view/edit share modes. + +### Launch Categories + +- Developer Tools +- Productivity +- Writing Tools +- Open Source +- Documentation + +### Launch CTA + +Try the live demo, import a Markdown file, and tell us which docs workflow should be improved next. + +### Social Post + +Markdown docs are not simple anymore. They include code, Mermaid, LaTeX, frontmatter, alerts, and export needs. + +Markdown Viewer is an open-source, client-side Markdown workspace for technical docs: + +- Live GitHub-style preview +- Mermaid and MathJax +- GitHub import +- Multi-document tabs +- Markdown/HTML/PDF export +- View/edit share links + +Repo: https://github.com/ThisIs-Developer/Markdown-Viewer + +## Section 9 - Open Source Showcase Article + +# Markdown Viewer: Open-Source Markdown Tooling You Can Inspect, Run, And Improve + +Open-source Markdown tools should be easy to understand. + +That is one of the best things about Markdown Viewer. The application is not hidden behind a complex backend, proprietary sync layer, or opaque document service. It is a static web app with a visible repository, an Apache-2.0 license at the root, a wiki, a changelog, Docker support, GitHub workflows, and a desktop wrapper that shares the same core files. + +For developers and documentation teams, that transparency matters. Markdown is often where project knowledge lives: READMEs, runbooks, release notes, architecture docs, API guides, incident reviews, and onboarding material. A tool that renders those documents should be inspectable. + +Markdown Viewer is built with HTML, CSS, and vanilla JavaScript. Rendering is powered by well-known open-source libraries: marked.js, highlight.js, MathJax, Mermaid, DOMPurify, js-yaml, FileSaver.js, html2canvas, jsPDF, pako, Bootstrap, and JoyPixels. The desktop app uses Neutralinojs. + +The repository includes: + +- A root README with quick start, features, screenshots, deployment options, and links. +- A full wiki for installation, usage, features, Markdown syntax, desktop app, Docker deployment, configuration, FAQ, contributing, and development journey. +- A changelog documenting code changes through v3.7.3. +- A contributing guide with setup steps, code style, issue guidance, PR process, and Conventional Commits. +- Docker and Docker Compose files. +- GitHub Actions for Docker publishing and desktop release builds. +- A service worker and manifest for PWA-style browser installation. + +The project is also transparent about data flow. Markdown is processed locally in the browser. Preferences and content are stored in localStorage. Share links encode content into the URL hash rather than uploading it to an app server. GitHub import uses public GitHub APIs and raw file URLs. CDN dependencies are disclosed, and the docs explain that users can self-host dependencies for isolated environments. + +That kind of honesty is important. "Privacy" should not mean pretending the browser never touches the network. Markdown Viewer gives users a practical explanation of what stays local and what requests are made. + +From a contribution standpoint, the project has several attractive surfaces: + +- Rendering improvements for Markdown extensions. +- Accessibility improvements around modals, tabs, keyboard flows, and mobile. +- Export-quality improvements for PDF and HTML. +- GitHub import usability. +- Desktop packaging and platform support. +- Internationalization and translation quality. +- Documentation examples and templates. +- Performance profiling for large documents. +- Tests for toolbar actions, rendering edge cases, and export output. + +The app also has a clear technical philosophy: keep the core static, keep workflows inspectable, and add power where Markdown users actually need it. Multi-document tabs, Mermaid export, MathJax, frontmatter tables, scoped find-and-replace, GitHub import, and self-hosting all serve that philosophy. + +It is not a cloud collaboration product. It is not a vault-based PKM app. It is not a full IDE. That clarity helps contributors. A good open-source project should give people a shape they can understand. + +Markdown Viewer is a strong fit for open-source communities because the product and the audience overlap. The people who write README files, GitHub docs, changelogs, and project specs are also the people who can help improve the tool. Every better rendering example, every accessibility fix, every export edge case, and every documentation template makes the project more useful to the same community that maintains it. + +For maintainers, the next community step should be to turn that openness into a contributor path: + +- Add "good first issue" labels for documentation, rendering examples, translations, and UI bugs. +- Add a small test matrix for Markdown rendering features. +- Create template documents for README, ADR, changelog, release notes, and Mermaid architecture diagrams. +- Publish a roadmap focused on rendering accuracy, export quality, desktop builds, and accessibility. +- Use GitHub Discussions for feedback on Markdown extension priorities. + +Open-source tools grow when users can see themselves in the product. Markdown Viewer has that advantage. It is built for the same open, text-based documentation culture it invites people to contribute to. + +## Section 10 - Community Growth Strategy + +### Positioning Thesis + +Markdown Viewer should be promoted as a focused, open-source Markdown workspace for technical documents. The main wedge is not "another Markdown editor." The wedge is "render and edit complex technical Markdown with source visible, no account, GitHub import, diagrams, math, tabs, export, and self-hosting." + +### Primary Messaging Pillars + +1. Technical rendering depth: GFM, code, Mermaid, MathJax, frontmatter, alerts, footnotes, definition lists. +2. Practical workflows: multi-document tabs, GitHub import, export, share links, advanced find-and-replace. +3. Local/control story: client-side processing, no app server storing docs, no analytics, static/Docker/desktop options. +4. Open-source transparency: Apache-2.0 root license, visible code, documented data flow, contribution guide. +5. AI-era relevance: AI-generated Markdown often includes code, diagrams, tables, and math that need a real preview. + +### SEO Opportunities + +Core pages to create: + +- `/markdown-viewer` +- `/online-markdown-editor` +- `/github-markdown-viewer` +- `/mermaid-markdown-viewer` +- `/latex-markdown-editor` +- `/markdown-to-html` +- `/markdown-to-pdf` +- `/open-source-markdown-editor` +- `/ai-generated-markdown-viewer` +- `/README-editor` + +Keyword clusters: + +- Core utility: online markdown editor, markdown editor with live preview, free markdown editor, no signup markdown editor. +- Viewer intent: markdown viewer, online markdown viewer, view markdown file online, GitHub markdown viewer. +- Technical rendering: markdown viewer mermaid, markdown editor LaTeX, Markdown MathJax preview, GFM preview. +- Conversion: markdown to PDF, markdown to HTML, standalone HTML Markdown export. +- Open source: open-source markdown editor, self-hosted markdown editor, Docker markdown editor. +- AI workflow: AI generated markdown viewer, ChatGPT markdown viewer, Claude markdown viewer, architecture docs Mermaid viewer. +- Alternatives: Typora alternative, Obsidian alternative for Markdown preview, VS Code Markdown preview alternative. + +Content formats: + +- Tool-embedded SEO pages, not generic blog posts only. +- Feature tutorials with real Markdown examples. +- Comparison guides with honest trade-offs. +- Template packs: README, ADR, PRD, runbook, changelog, release notes, architecture diagram. +- "How to render AI-generated Mermaid and LaTeX Markdown locally." +- "How to preview GitHub Markdown before committing." +- "How to export Markdown with diagrams to standalone HTML." + +### Reddit Strategy + +Post only where the angle fits. Avoid link-only promotion. + +Good candidates: + +- `r/Markdown`: technical Markdown rendering, Mermaid/LaTeX support, request feedback on syntax support. +- `r/webdev`: browser/static app architecture and open-source implementation. +- `r/selfhosted`: Docker/static hosting and no app server storing documents. +- `r/github`: GitHub import, README preview, open-source project. +- `r/technicalwriting`: docs-as-code, export, alerts, diagrams. +- `r/SideProject`: build story and product feedback. +- `r/opensource`: contributor path and transparent tooling. +- `r/vscode`: only if framed as a dedicated preview companion, not a VS Code replacement. + +Rules: + +- Lead with the problem and context. +- Include screenshots or a short GIF. +- Mention limitations. +- Ask for specific feedback. +- Do not cross-post identical text. +- Follow each subreddit rules and Reddit's spam guidance. + +### Hacker News Strategy + +Use a neutral Show HN title: + +Show HN: Open-source Markdown viewer for technical docs with Mermaid and LaTeX + +HN copy should be short, technical, transparent, and non-salesy. Do not use uppercase, hype words, or ask for upvotes. Be ready to answer questions about renderer choice, security, CDN use, local storage, Electron vs Neutralino, export limits, and how it differs from VS Code/Obsidian/Typora. + +### Dev.to Strategy + +Dev.to should be used for technical articles, not just a launch blast. + +Suggested posts: + +- "Building a client-side Markdown previewer with Mermaid and MathJax." +- "How to render AI-generated Markdown with diagrams locally." +- "Why Markdown export gets hard when docs include Mermaid, LaTeX, and code." +- "A walkthrough of Markdown Viewer's GitHub import flow." +- "How to build a static, self-hostable Markdown tool with vanilla JavaScript." + +Suggested tags: + +- `markdown` +- `opensource` +- `webdev` +- `javascript` +- `documentation` +- `github` + +### GitHub Growth Strategy + +Repository improvements: + +- Add topics: `markdown-viewer`, `markdown-editor`, `gfm`, `mermaid`, `mathjax`, `documentation`, `docs-as-code`, `pwa`, `neutralinojs`, `docker`, `open-source`. +- Add a short demo GIF above the fold. +- Add a feature matrix that includes limitations. +- Add issue templates for bug, feature request, rendering issue, export issue, accessibility issue. +- Add "good first issue" and "help wanted" labels. +- Add a roadmap section. +- Add rendering fixture examples for footnotes, alerts, frontmatter, math, Mermaid, and export edge cases. +- Submit to relevant awesome lists such as Awesome Markdown Editors after README polish. + +### Product Hunt Strategy + +Pre-launch: + +- Prepare 5 visuals and one 30-45 second demo. +- Make sure live demo loads quickly. +- Ensure README top section has a clear screenshot, live demo link, Docker command, and desktop/download link. +- Prepare maker comment and first response templates. +- Create a feedback loop in GitHub Discussions. + +Launch day: + +- Launch when the maintainer can reply quickly for the full day. +- Ask people to visit, try, and comment, not to upvote. +- Share in relevant communities with customized context. +- Keep the CTA specific: "Try importing a public GitHub docs folder" or "Paste a Mermaid-heavy Markdown file." + +Post-launch: + +- Publish a changelog-driven follow-up. +- Turn common feedback into issues. +- Ship one small improvement within a week if possible. +- Thank contributors and commenters publicly. + +### Content Calendar + +Week 1: + +- Publish the feature showcase. +- Update README with demo GIF, limitation note, and feature matrix. +- Post to GitHub Discussions and r/Markdown. + +Week 2: + +- Publish the technical deep dive on Dev.to. +- Post a concise Show HN if the live demo and README are polished. +- Add template pack to the repo. + +Week 3: + +- Publish comparison article: Markdown Viewer vs Obsidian vs Typora vs VS Code for technical docs. +- Submit to open-source directories and awesome lists. +- Add good-first-issue labels. + +Week 4: + +- Product Hunt launch. +- Publish a "What we learned from launch feedback" post. +- Prioritize export, rendering, and GitHub import issues. + +### Metrics To Track + +- GitHub stars and watchers. +- Forks and contributor count. +- Issues opened by real users. +- Accepted pull requests. +- Live demo visits. +- Docker pulls or GHCR package activity. +- README click-through to live demo. +- Search impressions for target SEO pages. +- Community comments with feature requests. +- Repeat contributors. + +### Risk And Mitigation + +- Risk: "Yet another Markdown editor." + Mitigation: Lead with technical docs, Mermaid/LaTeX, GitHub import, advanced replace, and self-hosting. + +- Risk: Privacy claims challenged because CDNs/GitHub import use network. + Mitigation: Use precise wording: no app server storage, client-side processing, CDN/GitHub requests disclosed. + +- Risk: Competitors are broader and more polished. + Mitigation: Do not compete on breadth. Compete on focus, openness, deployment, and technical-doc workflows. + +- Risk: PDF export quality varies. + Mitigation: State the limitation and recommend browser print-to-PDF for complex documents. + +- Risk: Copy button docs inconsistency. + Mitigation: Use "copy Markdown" in marketing until implementation and docs align. + +- Risk: Metadata and test-coverage inconsistencies are noticed by developers. + Mitigation: Clarify desktop license wording, sync package-lock version metadata, and either add the referenced tests or revise changelog/docs language. + +## External Research Sources + +- Obsidian overview and local-file positioning: https://obsidian.md/ +- Obsidian vault docs: https://obsidian.md/help/vault +- Typora export docs: https://support.typora.io/Export/ +- Typora diagram docs: https://support.typora.io/Draw-Diagrams-With-Markdown/ +- MarkText repository: https://github.com/marktext/marktext +- MarkText website: https://marktext.me/ +- StackEdit feature page: https://stackedit.io/ +- Dillinger feature page: https://dillinger.io/features +- VS Code Markdown docs: https://code.visualstudio.com/docs/languages/markdown +- Hacker News guidelines: https://news.ycombinator.com/newsguidelines.html +- Product Hunt launch guide: https://www.producthunt.com/launch +- Reddit spam guidance: https://support.reddithelp.com/hc/en-us/articles/360043504051-Spam +- Dev.to editor guide: https://dev.to/p/editor_guide/ +- Open-source contributor onboarding guidance: https://opensource.com/article/19/12/open-source-contributors + +## Final Approval + +Chief Product Marketing Officer review: + +- Product was analyzed first: yes. +- Repository, docs, changelog, implementation, Docker, workflows, and desktop wrapper were inspected: yes. +- Claims are based on actual code, docs, or cited competitor/community sources: yes. +- Stale or conflicting documentation claims were corrected in the marketing copy: yes. +- Unsupported claims such as cloud sync, collaboration, DOCX export, AI assistant features, token counting, plugin ecosystem, and rendered-HTML copy were excluded: yes. +- Competitive positioning avoids exaggerated "better than" claims: yes. +- Articles are written for developers, students, technical writers, documentation teams, open-source enthusiasts, and productivity users: yes. +- Community posts are transparent, non-hype, and ready for adaptation by channel: yes. + +Final Status: APPROVED FOR PUBLICATION diff --git a/desktop-app/resources/js/preview-worker.js b/desktop-app/resources/js/preview-worker.js index ca193729..4a97c054 100644 --- a/desktop-app/resources/js/preview-worker.js +++ b/desktop-app/resources/js/preview-worker.js @@ -350,7 +350,7 @@ function ensureLibraries(urls) { } function isSegmentedPreviewSafe(markdown) { - if (/^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/.test(markdown)) return false; + if (/^\s*---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/.test(markdown)) return false; if (/^\[[^\]\n]+\]:\s+\S+/m.test(markdown)) return false; if (/\[\^[^\]\n]+\]/.test(markdown)) return false; if (/\n:[ \t]+/.test(markdown)) return false; diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index 631398d5..b19f2af0 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -453,7 +453,7 @@ document.addEventListener("DOMContentLoaded", function () { function isSegmentedPreviewSafe(markdown) { if (!markdown || markdown.length < PREVIEW_WORKER_THRESHOLD) return false; - if (/^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/.test(markdown)) return false; + if (/^\s*---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/.test(markdown)) return false; if (/^\[[^\]\n]+\]:\s+\S+/m.test(markdown)) return false; if (/\[\^[^\]\n]+\]/.test(markdown)) return false; if (/\n:[ \t]+/.test(markdown)) return false; @@ -1043,7 +1043,7 @@ document.addEventListener("DOMContentLoaded", function () { } function parseFrontmatter(markdown) { - const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---(\r?\n|$)/); + const match = markdown.match(/^\s*---\r?\n([\s\S]*?)\r?\n---(\r?\n|$)/); if (!match) return { frontmatter: null, body: markdown }; try { const data = jsyaml.load(match[1]) || {}; @@ -6538,7 +6538,8 @@ document.addEventListener("DOMContentLoaded", function () { const html = tableHtml + marked.parse(referenceData.cleanedMarkdown); const sanitizedHtml = DOMPurify.sanitize(html, { ADD_TAGS: ['mjx-container', 'input'], - ADD_ATTR: ['id', 'class', 'style', 'align', 'type', 'checked', 'disabled', 'data-original-code'] + ADD_ATTR: ['id', 'class', 'style', 'align', 'type', 'checked', 'disabled', 'data-original-code'], + ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel|blob):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i }); const tempContainer = document.createElement("div"); tempContainer.innerHTML = sanitizedHtml; @@ -6724,10 +6725,15 @@ document.addEventListener("DOMContentLoaded", function () { if (overflow <= 1) return; var styles = window.getComputedStyle(article); + var paddingLeft = parseFloat(styles.paddingLeft) || 0; var paddingRight = parseFloat(styles.paddingRight) || 0; var borderRight = parseFloat(styles.borderRightWidth) || 0; var borderLeft = parseFloat(styles.borderLeftWidth) || 0; - var requiredWidth = Math.ceil(article.scrollWidth + paddingRight + borderLeft + borderRight); + var boxSizing = styles.boxSizing; + + var requiredWidth = boxSizing === 'border-box' + ? Math.ceil(article.scrollWidth + borderLeft + borderRight) + : Math.ceil(article.scrollWidth - paddingLeft - paddingRight); article.style.width = requiredWidth + 'px'; article.style.maxWidth = 'none'; @@ -6794,7 +6800,7 @@ document.addEventListener("DOMContentLoaded", function () { scale: 2 // html2canvas scale factor }; - const PDF_EXPORT_DEBUG = false; + const PDF_EXPORT_DEBUG = true; let activePdfExport = null; class PdfExportCancelledError extends Error { @@ -6943,7 +6949,12 @@ document.addEventListener("DOMContentLoaded", function () { state.cleanedUp = true; if (state.tempElement && state.tempElement.parentNode) { - state.tempElement.parentNode.removeChild(state.tempElement); + if (window.keepTempElementForAudit) { + window.auditedTempElement = state.tempElement; + console.log("Skipped tempElement removal for audit"); + } else { + state.tempElement.parentNode.removeChild(state.tempElement); + } } if (state.overlay && state.overlay.parentNode) { state.overlay.parentNode.removeChild(state.overlay); @@ -6996,8 +7007,8 @@ document.addEventListener("DOMContentLoaded", function () { const boxSizing = window.getComputedStyle(element).boxSizing; const requiredWidth = boxSizing === 'border-box' - ? Math.ceil(element.scrollWidth + paddingRight + borderLeft + borderRight) - : Math.ceil(element.scrollWidth - paddingLeft + paddingRight); + ? Math.ceil(element.scrollWidth + borderLeft + borderRight) + : Math.ceil(element.scrollWidth - paddingLeft - paddingRight); element.style.width = `${requiredWidth}px`; return true; @@ -7012,19 +7023,142 @@ document.addEventListener("DOMContentLoaded", function () { const graphics = []; // Query all targeting elements in precise DOM layout flow order - container.querySelectorAll('img, svg, pre, table').forEach(el => { - let type = 'img'; + container.querySelectorAll('img, svg, pre, table, p, li, h1, h2, h3, h4, h5, h6, blockquote, hr, .math-block, mjx-container[display="true"]').forEach(el => { const tag = el.tagName.toLowerCase(); - if (tag === 'svg') type = 'svg'; + + // Skip any elements nested inside blockquotes to treat blockquotes as atomic containers + if (el.parentElement && el.parentElement.closest('blockquote')) { + return; + } + + // Skip any elements nested inside list items that contain block children (treat list items as atomic) + if (el.parentElement) { + const liAncestor = el.parentElement.closest('li'); + if (liAncestor) { + const hasBlockChildren = liAncestor.querySelector('p, blockquote, pre, table, ul, ol') !== null; + if (hasBlockChildren) { + return; + } + } + } + + let type = ''; + + if (tag === 'img') type = 'img'; + else if (tag === 'svg') { + if (!el.closest('mjx-container, .math-block')) { + type = 'svg'; + } + } else if (tag === 'pre') type = 'pre'; else if (tag === 'table') type = 'table'; + else if (tag === 'hr') type = 'hr'; + else if (tag === 'blockquote') { + type = 'blockquote'; + } + else if (['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tag)) { + type = 'text'; + } else if (tag === 'li') { + // Treat list items with block children as atomic containers, otherwise treat as text + const hasBlockChildren = el.querySelector('p, blockquote, pre, table, ul, ol') !== null; + if (hasBlockChildren) { + type = 'li'; + } else { + type = 'text'; + } + } else if (el.classList.contains('math-block') || tag === 'mjx-container') { + type = 'math'; + } - graphics.push({ element: el, type: type }); + if (type) { + graphics.push({ element: el, type: type }); + } }); return graphics; } + /** + * Calculates the computed line-height of a text element, defaulting based on tag if "normal" + * @param {HTMLElement} element + * @returns {number} The line-height in pixels + */ + function getElementLineHeight(element) { + const style = window.getComputedStyle(element); + const fontSize = parseFloat(style.fontSize) || 14; + let lineHeight = parseFloat(style.lineHeight); + + if (isNaN(lineHeight)) { + const tag = element.tagName.toLowerCase(); + if (tag.startsWith('h')) { + lineHeight = fontSize * 1.25; + } else { + lineHeight = fontSize * 1.5; + } + } else if (lineHeight < 10) { + // Handle unitless line-height (e.g. "1.5") + lineHeight = lineHeight * fontSize; + } + return lineHeight; + } + + /** + * Calculates the shift needed to align a split text element's lines with the page boundary + * @param {Object} item - The split element item + * @param {Array} pageBoundaries - Page break positions + * @returns {number} The shift in pixels + */ + function calculateTextElementShift(item, pageBoundaries) { + const boundaryY = pageBoundaries[item.splitPageIndex]; + if (boundaryY === undefined) return 0; + + const element = item.element; + const style = window.getComputedStyle(element); + + const tag = element.tagName.toLowerCase(); + const isHeading = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tag); + + // Safety buffer (in pixels) to ensure text rendering, glyph ascenders, + // and sub-pixel anti-aliasing are pushed cleanly past the slicing boundary. + const SAFETY_BUFFER = 4; + + // Headings should never be split. Push the entire heading to the next page. + if (isHeading) { + return (boundaryY - item.top) + SAFETY_BUFFER; + } + + const paddingTop = parseFloat(style.paddingTop) || 0; + const borderTop = parseFloat(style.borderTopWidth) || 0; + const paddingBottom = parseFloat(style.paddingBottom) || 0; + const borderBottom = parseFloat(style.borderBottomWidth) || 0; + + const lh = getElementLineHeight(element); + const contentTop = item.top + paddingTop + borderTop; + const contentHeight = item.height - paddingTop - paddingBottom - borderTop - borderBottom; + if (contentHeight <= 0) return 0; + + const numLines = Math.max(1, Math.round(contentHeight / lh)); + + for (let i = 0; i < numLines; i++) { + const lineTop = contentTop + i * lh; + const lineBottom = contentTop + (i + 1) * lh; + + // Check if this line is split by boundaryY (using a small tolerance to prevent float vibrations) + if (lineTop < boundaryY - 0.5 && lineBottom > boundaryY + 0.5) { + // Calculate shift to align the line's top with boundaryY, plus a safety buffer + return (boundaryY - lineTop) + SAFETY_BUFFER; + } + } + + // Fallback: If a short paragraph/list item is split across pages but no single line is cut + // (e.g. boundary falls exactly in padding/margin), push the whole element. + if (item.height <= lh * 3) { + return (boundaryY - item.top) + SAFETY_BUFFER; + } + + return 0; + } + /** * Task 2: Calculates element positions relative to the container * @param {Array} elements - Array of {element, type} objects @@ -7058,9 +7192,6 @@ document.addEventListener("DOMContentLoaded", function () { * @returns {Array} Array of y-coordinates where pages end */ function calculatePageBoundaries(totalHeight, elementWidth, pageConfig) { - // Calculate pixel height per page based on the element's actual width - // This must match how PDF pagination will split the canvas - // The aspect ratio of content area determines page height relative to width const aspectRatio = pageConfig.contentHeight / pageConfig.contentWidth; const pageHeightPx = elementWidth * aspectRatio; @@ -7082,20 +7213,12 @@ document.addEventListener("DOMContentLoaded", function () { * @returns {Array} Array of split elements with additional split info */ function detectSplitElements(elements, pageBoundaries) { - // Handle edge case: empty elements array - if (!elements || elements.length === 0) { - return []; - } - - // Handle edge case: no page boundaries (single page) - if (!pageBoundaries || pageBoundaries.length === 0) { - return []; - } + if (!elements || elements.length === 0) return []; + if (!pageBoundaries || pageBoundaries.length === 0) return []; const splitElements = []; for (const item of elements) { - // Find which page the element starts on let startPage = 0; for (let i = 0; i < pageBoundaries.length; i++) { if (item.top >= pageBoundaries[i]) { @@ -7105,7 +7228,6 @@ document.addEventListener("DOMContentLoaded", function () { } } - // Find which page the element ends on let endPage = 0; for (let i = 0; i < pageBoundaries.length; i++) { if (item.bottom > pageBoundaries[i]) { @@ -7115,9 +7237,7 @@ document.addEventListener("DOMContentLoaded", function () { } } - // Element is split if it spans multiple pages if (endPage > startPage) { - // Calculate overflow amount (how much crosses into next page) const boundaryY = pageBoundaries[startPage] || pageBoundaries[0]; const overflowAmount = item.bottom - boundaryY; @@ -7144,23 +7264,12 @@ document.addEventListener("DOMContentLoaded", function () { try { throwIfPdfExportAborted(signal); - // Step 1: Identify all graphic elements const graphics = identifyGraphicElements(tempElement); - logPdfExportDebug('Step 1 - Graphics found:', graphics.length, graphics.map(g => g.type)); - - // Step 2: Calculate positions for each element const elementsWithPositions = calculateElementPositions(graphics, tempElement); - logPdfExportDebug('Step 2 - Element positions:', elementsWithPositions.map(e => ({ - type: e.type, - top: Math.round(e.top), - height: Math.round(e.height), - bottom: Math.round(e.bottom) - }))); throwIfPdfExportAborted(signal); - // Step 3: Calculate page boundaries using the element's ACTUAL width - const totalHeight = tempElement.scrollHeight; + const totalHeight = Math.ceil(tempElement.getBoundingClientRect().height); const elementWidth = tempElement.offsetWidth; const { boundaries: pageBoundaries, pageHeightPx } = calculatePageBoundaries( totalHeight, @@ -7168,18 +7277,7 @@ document.addEventListener("DOMContentLoaded", function () { PAGE_CONFIG ); - logPdfExportDebug('Step 3 - Page boundaries:', { - elementWidth, - totalHeight, - pageHeightPx: Math.round(pageHeightPx), - boundaries: pageBoundaries.map(b => Math.round(b)) - }); - - // Step 4: Detect split elements const splitElements = detectSplitElements(elementsWithPositions, pageBoundaries); - logPdfExportDebug('Step 4 - Split elements detected:', splitElements.length); - - // Calculate page count const pageCount = pageBoundaries.length + 1; return { @@ -7210,144 +7308,367 @@ document.addEventListener("DOMContentLoaded", function () { // Page-Break Insertion Functions (Story 1.2) // ============================================ - // Threshold for whitespace optimization (30% of page height) - const PAGE_BREAK_THRESHOLD = 0.3; + /** - * Task 3: Categorizes split elements by whether they fit on a single page - * @param {Array} splitElements - Array of split elements from detection - * @param {number} pageHeightPx - Page height in pixels - * @returns {Object} { fittingElements, oversizedElements } + * Resets temporary styles applied to graphics elements back to their original state. + * This is called at the start of each layout iteration in the cascade loop. + * @param {HTMLElement} container - The container element to process */ - function categorizeBySize(splitElements, pageHeightPx) { - const fittingElements = []; - const oversizedElements = []; - - for (const item of splitElements) { - if (item.height <= pageHeightPx) { - fittingElements.push(item); - } else { - oversizedElements.push(item); - } - } + function resetGraphicsStyles(container) { + // Remove all dynamically inserted page-break spacers + container.querySelectorAll('.pdf-page-break-spacer').forEach(el => el.remove()); - return { fittingElements, oversizedElements }; + container.querySelectorAll('[data-pdf-original-margin-top]').forEach(el => { + el.style.marginTop = el.dataset.pdfOriginalMarginTop; + el.removeAttribute('data-pdf-original-margin-top'); + }); + container.querySelectorAll('[data-pdf-original-margin-bottom]').forEach(el => { + el.style.marginBottom = el.dataset.pdfOriginalMarginBottom; + el.removeAttribute('data-pdf-original-margin-bottom'); + }); + container.querySelectorAll('[data-pdf-original-transform]').forEach(el => { + el.style.transform = el.dataset.pdfOriginalTransform; + el.style.transformOrigin = ''; + el.removeAttribute('data-pdf-original-transform'); + }); + container.querySelectorAll('[data-pdf-original-width]').forEach(el => { + el.style.width = el.dataset.pdfOriginalWidth; + el.removeAttribute('data-pdf-original-width'); + }); + container.querySelectorAll('[data-pdf-original-height]').forEach(el => { + el.style.height = el.dataset.pdfOriginalHeight; + el.removeAttribute('data-pdf-original-height'); + }); + container.querySelectorAll('[data-pdf-original-max-width]').forEach(el => { + el.style.maxWidth = el.dataset.pdfOriginalMaxWidth; + el.removeAttribute('data-pdf-original-max-width'); + }); + container.querySelectorAll('[data-pdf-original-font-size]').forEach(el => { + el.style.fontSize = el.dataset.pdfOriginalFontSize; + el.removeAttribute('data-pdf-original-font-size'); + }); + container.querySelectorAll('[data-pdf-original-overflow]').forEach(el => { + el.style.overflow = el.dataset.pdfOriginalOverflow; + el.removeAttribute('data-pdf-original-overflow'); + }); } - /** - * Task 1: Inserts page breaks by adjusting margins for fitting elements - * @param {Array} fittingElements - Elements that fit on a single page - * @param {number} pageHeightPx - Page height in pixels - */ - function insertPageBreaks(fittingElements, pageHeightPx, signal) { - for (const item of fittingElements) { - throwIfPdfExportAborted(signal); - - // Calculate where the current page ends - const currentPageBottom = (item.splitPageIndex + 1) * pageHeightPx; + function mergeSplitTables(container) { + const groupIds = new Set(); + container.querySelectorAll('table[data-split-group-id]').forEach(t => { + if (t.dataset.splitGroupId) { + groupIds.add(t.dataset.splitGroupId); + } + }); - // Calculate remaining space on current page - const remainingSpace = currentPageBottom - item.top; - const remainingRatio = remainingSpace / pageHeightPx; + for (const groupId of groupIds) { + const originalTable = container.querySelector(`table[data-split-group-id="${groupId}"]:not([data-split-part="true"])`); + if (!originalTable) continue; - logPdfExportDebug('Processing split element:', { - type: item.type, - top: Math.round(item.top), - height: Math.round(item.height), - splitPageIndex: item.splitPageIndex, - currentPageBottom: Math.round(currentPageBottom), - remainingSpace: Math.round(remainingSpace), - remainingRatio: remainingRatio.toFixed(2) - }); + const parts = Array.from(container.querySelectorAll(`table[data-split-group-id="${groupId}"][data-split-part="true"]`)); + const tbody = originalTable.tBodies[0] || originalTable.querySelector('tbody') || originalTable; - // Task 4: Whitespace optimization - // If remaining space is more than threshold and element almost fits, skip - // (Will be handled by Story 1.3 scaling instead) - if (remainingRatio > PAGE_BREAK_THRESHOLD) { - const scaledHeight = item.height * 0.9; // 90% scale - if (scaledHeight <= remainingSpace) { - logPdfExportDebug(' -> Skipping (can fit with 90% scaling)'); - continue; + for (const part of parts) { + const partTbody = part.tBodies[0] || part.querySelector('tbody') || part; + const rows = Array.from(partTbody.children).filter(child => child.tagName.toLowerCase() === 'tr'); + for (const row of rows) { + tbody.appendChild(row); } + part.remove(); + } + + const spacers = Array.from(container.querySelectorAll(`div[data-split-group-id="${groupId}"][data-split-spacer="true"]`)); + for (const spacer of spacers) { + spacer.remove(); } - // Calculate margin needed to push element to next page - const marginNeeded = currentPageBottom - item.top + 5; // 5px buffer + originalTable.removeAttribute('data-split-group-id'); + } + } + + function splitTables(container, pageHeightPx) { + mergeSplitTables(container); - logPdfExportDebug(' -> Applying marginTop:', marginNeeded, 'px'); + const tables = Array.from(container.querySelectorAll('table')); + let groupCounter = 0; - // Determine which element to apply margin to - // For SVG elements (Mermaid diagrams), apply to parent container for proper layout - let targetElement = item.element; - if (item.type === 'svg' && item.element.parentElement) { - targetElement = item.element.parentElement; - logPdfExportDebug(' -> Using parent element:', targetElement.tagName, targetElement.className); - } + for (const table of tables) { + if (table.dataset.splitPart === "true") continue; + const tableRect = table.getBoundingClientRect(); + if (tableRect.height <= pageHeightPx) continue; + + const tbody = table.tBodies[0] || table.querySelector('tbody'); + if (!tbody) continue; - // Apply margin to push element to next page - const currentMargin = parseFloat(targetElement.style.marginTop) || 0; - targetElement.style.marginTop = `${currentMargin + marginNeeded}px`; + const rows = Array.from(tbody.children).filter(child => child.tagName.toLowerCase() === 'tr'); + if (rows.length === 0) continue; - logPdfExportDebug(' -> Element after margin:', targetElement.tagName, 'marginTop =', targetElement.style.marginTop); + const groupId = `table-group-${groupCounter++}`; + table.dataset.splitGroupId = groupId; + const containerRect = container.getBoundingClientRect(); + + const rowPositions = rows.map(row => { + const rect = row.getBoundingClientRect(); + return { + row: row, + top: rect.top - containerRect.top, + bottom: rect.bottom - containerRect.top, + height: rect.height + }; + }); + + let currentTable = table; + let currentTbody = tbody; + let accumulatedShift = 0; + + for (let i = 0; i < rowPositions.length; i++) { + const pos = rowPositions[i]; + const shiftedTop = pos.top + accumulatedShift; + const shiftedBottom = pos.bottom + accumulatedShift; + const currentPageIndex = Math.floor(shiftedTop / pageHeightPx); + const nextPageBoundary = (currentPageIndex + 1) * pageHeightPx; + + if (shiftedBottom > nextPageBoundary) { + const spacerHeight = nextPageBoundary - shiftedTop; + const originalThead = table.querySelector('thead'); + const theadHeight = originalThead ? originalThead.getBoundingClientRect().height : 0; + accumulatedShift += spacerHeight + theadHeight; + + const nextTable = table.cloneNode(false); + nextTable.removeAttribute('id'); + nextTable.dataset.splitGroupId = groupId; + nextTable.dataset.splitPart = "true"; + + if (originalThead) nextTable.appendChild(originalThead.cloneNode(true)); + const nextTbody = document.createElement('tbody'); + nextTable.appendChild(nextTbody); + + const spacer = document.createElement('div'); + spacer.className = 'table-page-break-spacer'; + spacer.dataset.splitGroupId = groupId; + spacer.dataset.splitSpacer = "true"; + spacer.style.height = `${spacerHeight}px`; + spacer.style.margin = '0'; + spacer.style.padding = '0'; + spacer.style.border = 'none'; + + currentTable.parentNode.insertBefore(spacer, currentTable.nextSibling); + spacer.parentNode.insertBefore(nextTable, spacer.nextSibling); + currentTable = nextTable; + currentTbody = nextTbody; + } + + if (currentTable !== table) currentTbody.appendChild(pos.row); + } } } - /** - * Task 2: Applies page breaks with cascading adjustment handling - * @param {HTMLElement} tempElement - The rendered content container - * @param {Object} pageConfig - Page configuration object (unused, kept for API compatibility) - * @param {number} maxIterations - Maximum iterations to prevent infinite loops - * @returns {Object} Final analysis result - */ function applyPageBreaksWithCascade(tempElement, pageConfig, maxIterations = 10, signal) { let iteration = 0; let analysis; - let previousSplitCount = -1; + + const elementWidth = tempElement.offsetWidth; + const aspectRatio = pageConfig.contentHeight / pageConfig.contentWidth; + const pageHeightPx = elementWidth * aspectRatio; + + const lastAdjustments = new Map(); // Store {margin, scale} for each element do { throwIfPdfExportAborted(signal); - // Re-analyze after each adjustment - analysis = analyzeGraphicsForPageBreaks(tempElement, signal); + // Reset graphics element styles applied in previous iterations + resetGraphicsStyles(tempElement); - // Use pageHeightPx from analysis (calculated from actual element width) - const pageHeightPx = analysis.pageHeightPx; + // Split tables at page breaks dynamically using calculated pageHeightPx + splitTables(tempElement, pageHeightPx); - // Categorize elements by size - const { fittingElements, oversizedElements } = categorizeBySize( - analysis.splitElements, - pageHeightPx + // We must get ALL target elements in document order + const graphics = identifyGraphicElements(tempElement); + const elementsWithPositions = calculateElementPositions(graphics, tempElement); + + // Get page boundaries based on current height + const totalHeight = Math.ceil(tempElement.getBoundingClientRect().height); + const { boundaries: pageBoundaries, pageHeightPx: pageHeightPxFromAnalysis } = calculatePageBoundaries( + totalHeight, + elementWidth, + pageConfig ); - // Store oversized elements for Story 1.3 - analysis.oversizedElements = oversizedElements; + let adjustmentsMade = false; + let accumulatedShift = 0; + const currentIterationAdjustments = new Map(); - // If no fitting elements need adjustment, we're done - if (fittingElements.length === 0) { - break; + for (const item of elementsWithPositions) { + throwIfPdfExportAborted(signal); + + const currentTop = item.top + accumulatedShift; + const currentBottom = currentTop + item.height; + + let targetMargin = 0; + let targetScale = 1.0; + + // 1. Heading Keep-With-Next Rule (must run for all headings regardless of split) + const tag = item.element.tagName.toLowerCase(); + const isHeading = item.type === 'text' && ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tag); + + if (isHeading) { + let nextBoundaryY = null; + for (const boundary of pageBoundaries) { + if (currentTop < boundary) { + nextBoundaryY = boundary; + break; + } + } + if (nextBoundaryY !== null) { + const distanceToBoundary = nextBoundaryY - currentTop; + if (distanceToBoundary < 70) { + targetMargin = distanceToBoundary + 4; // Push heading entirely to next page + } + } + } + + // 2. If not already pushed by Keep-With-Next, perform standard page-split calculations + if (targetMargin === 0) { + // Check if this element crosses any page boundary or starts extremely close to it (sub-pixel safety) + let splitPageIndex = -1; + for (let i = 0; i < pageBoundaries.length; i++) { + if (currentTop < pageBoundaries[i] + 12 && currentBottom > pageBoundaries[i]) { + splitPageIndex = i; + break; + } + } + + if (splitPageIndex !== -1) { + const boundaryY = pageBoundaries[splitPageIndex]; + const remainingSpace = boundaryY - currentTop; + + if (item.type === 'text') { + const shiftedItem = { ...item, top: currentTop, splitPageIndex: splitPageIndex }; + const shift = calculateTextElementShift(shiftedItem, pageBoundaries); + if (shift > 0.5) { + targetMargin = shift; + } + } else { + // Graphic element splitting (with larger buffer to ensure complete clearance) + const buffer = 15; + const scaleNeeded = (remainingSpace - buffer) / item.height; + const remainingSpacePercent = remainingSpace / pageHeightPxFromAnalysis; + + const isTextContainer = ['blockquote', 'li', 'table', 'pre', 'math'].includes(item.type); + + // Fit on current page by scaling if it's an image/svg and space/scale are acceptable. + // Otherwise, always push text/block containers to next page to prevent transform-scaling bugs. + if (!isTextContainer && remainingSpacePercent >= 0.20 && scaleNeeded >= 0.6) { + targetScale = Math.min(1.0, scaleNeeded); + } else { + // Push to next page + const marginNeeded = boundaryY - currentTop + buffer; + targetMargin = marginNeeded; + + // Check if it fits the next page height after being pushed (Rule 3 Case C) + const newTop = currentTop + marginNeeded; + const newBottom = newTop + item.height; + const nextBoundaryY = pageBoundaries[splitPageIndex + 1] || (boundaryY + pageHeightPxFromAnalysis); + if (newBottom > nextBoundaryY) { + const scaleToFitPage = (pageHeightPxFromAnalysis - 20) / item.height; + targetScale = Math.max(0.5, Math.min(1.0, scaleToFitPage)); + } + } + } + } else { + // Element is not split. But graphic elements taller than a page must still scale to fit! + if (item.type !== 'text' && item.height > pageHeightPxFromAnalysis) { + const scaleToFitPage = (pageHeightPxFromAnalysis - 20) / item.height; + targetScale = Math.max(0.5, Math.min(1.0, scaleToFitPage)); + } + } + } + + // Check if this calculated adjustment is different from the previous iteration + const prevAdjustment = lastAdjustments.get(item.element) || { margin: 0, scale: 1.0 }; + if (Math.abs(targetMargin - prevAdjustment.margin) > 0.1 || Math.abs(targetScale - prevAdjustment.scale) > 0.001) { + adjustmentsMade = true; + } + + currentIterationAdjustments.set(item.element, { margin: targetMargin, scale: targetScale }); + + // Apply style adjustments to the DOM + if (targetMargin > 0) { + let targetElement = item.element; + + // Redirect inline image or svg margins to their parent block element if nested + if (item.type === 'svg' && item.element.parentElement) { + const parent = item.element.parentElement; + if (['p', 'li', 'blockquote'].includes(parent.tagName.toLowerCase())) { + targetElement = parent; + } else { + targetElement = parent; + } + } else if (item.type === 'img' && item.element.parentElement) { + const parent = item.element.parentElement; + if (item.element.classList.contains('mermaid-img')) { + if (parent.parentElement && parent.parentElement.classList.contains('mermaid-container')) { + targetElement = parent.parentElement; + } else { + targetElement = parent; + } + } else if (['p', 'li', 'blockquote'].includes(parent.tagName.toLowerCase())) { + targetElement = parent; + } + } + + // If target is a list item, apply marginTop directly to avoid invalid HTML / collapsed spacers + if (targetElement.tagName.toLowerCase() === 'li') { + if (!targetElement.dataset.hasOwnProperty('pdfOriginalMarginTop')) { + targetElement.dataset.pdfOriginalMarginTop = targetElement.style.marginTop || ''; + } + targetElement.style.marginTop = `${targetMargin}px`; + } else { + // Create a physical spacer element to avoid margin collapse issues entirely + const spacer = document.createElement('div'); + spacer.className = 'pdf-page-break-spacer'; + spacer.style.height = `${targetMargin}px`; + spacer.style.margin = '0'; + spacer.style.padding = '0'; + spacer.style.border = 'none'; + spacer.style.display = 'block'; + + targetElement.parentNode.insertBefore(spacer, targetElement); + } + accumulatedShift += targetMargin; + } + + if (targetScale < 1.0) { + applyGraphicScaling(item.element, targetScale, item.type); + const heightSaved = item.height * (1.0 - targetScale); + accumulatedShift -= heightSaved; + } + } + + // Copy current adjustments to lastAdjustments + lastAdjustments.clear(); + for (const [el, adj] of currentIterationAdjustments) { + lastAdjustments.set(el, adj); } - // Check if we're making progress (prevent infinite loops) - if (fittingElements.length === previousSplitCount) { - console.warn('Page-break adjustment not making progress, stopping'); + if (!adjustmentsMade) { break; } - previousSplitCount = fittingElements.length; - // Apply page breaks to fitting elements - insertPageBreaks(fittingElements, pageHeightPx, signal); iteration++; - } while (iteration < maxIterations); if (iteration >= maxIterations) { console.warn('Page-break stabilization reached max iterations:', maxIterations); } + // Return the final page boundaries and height analysis for the export flow + analysis = analyzeGraphicsForPageBreaks(tempElement, signal); + logPdfExportDebug('Page-break cascade complete:', { iterations: iteration, - finalSplitCount: analysis.splitElements.length, - oversizedCount: analysis.oversizedElements ? analysis.oversizedElements.length : 0 + finalSplitCount: analysis.splitElements.length }); return analysis; @@ -7361,22 +7682,13 @@ document.addEventListener("DOMContentLoaded", function () { // Oversized Graphics Scaling Functions (Story 1.3) // ============================================ - // Minimum scale factor to maintain readability (50%) const MIN_SCALE_FACTOR = 0.5; - /** - * Task 1 & 2: Calculates scale factor with minimum enforcement - * @param {number} elementHeight - Original height of element in pixels - * @param {number} availableHeight - Available page height in pixels - * @param {number} buffer - Small buffer to prevent edge overflow - * @returns {Object} { scaleFactor, wasClampedToMin } - */ function calculateScaleFactor(elementHeight, availableHeight, buffer = 5) { const targetHeight = availableHeight - buffer; let scaleFactor = targetHeight / elementHeight; let wasClampedToMin = false; - // Enforce minimum scale for readability if (scaleFactor < MIN_SCALE_FACTOR) { console.warn( `Warning: Large graphic requires ${(scaleFactor * 100).toFixed(0)}% scaling. ` + @@ -7389,69 +7701,78 @@ document.addEventListener("DOMContentLoaded", function () { return { scaleFactor, wasClampedToMin }; } - /** - * Task 3: Applies CSS transform scaling to an element - * @param {HTMLElement} element - The element to scale - * @param {number} scaleFactor - Scale factor (0.5 = 50%) - * @param {string} elementType - Type of element (svg, pre, img, table) - */ function applyGraphicScaling(element, scaleFactor, elementType) { - // Get original dimensions before transform - const originalHeight = element.offsetHeight; - - // Task 4: Handle SVG elements (Mermaid diagrams) - if (elementType === 'svg') { - // Remove max-width constraint that may interfere - element.style.maxWidth = 'none'; + if (!element.dataset.hasOwnProperty('pdfOriginalTransform')) { + element.dataset.pdfOriginalTransform = element.style.transform || ''; + } + if (!element.dataset.hasOwnProperty('pdfOriginalMarginBottom')) { + element.dataset.pdfOriginalMarginBottom = element.style.marginBottom || ''; } - // Apply CSS transform - element.style.transform = `scale(${scaleFactor})`; - element.style.transformOrigin = 'top left'; + if (elementType === 'svg' || elementType === 'img') { + if (!element.dataset.hasOwnProperty('pdfOriginalWidth')) { + element.dataset.pdfOriginalWidth = element.style.width || ''; + } + if (!element.dataset.hasOwnProperty('pdfOriginalHeight')) { + element.dataset.pdfOriginalHeight = element.style.height || ''; + } + if (!element.dataset.hasOwnProperty('pdfOriginalMaxWidth')) { + element.dataset.pdfOriginalMaxWidth = element.style.maxWidth || ''; + } - // Calculate margin adjustment to collapse visual space - const scaledHeight = originalHeight * scaleFactor; - const marginAdjustment = originalHeight - scaledHeight; + let origWidth = parseFloat(element.dataset.pdfOriginalClientWidth); + let origHeight = parseFloat(element.dataset.pdfOriginalClientHeight); - // Apply negative margin to pull subsequent content up - element.style.marginBottom = `-${marginAdjustment}px`; - } + if (isNaN(origWidth) || isNaN(origHeight)) { + origWidth = element.clientWidth || element.getBoundingClientRect().width; + origHeight = element.clientHeight || element.getBoundingClientRect().height; + element.dataset.pdfOriginalClientWidth = String(origWidth); + element.dataset.pdfOriginalClientHeight = String(origHeight); + } - /** - * Task 6: Handles all oversized elements by applying appropriate scaling - * @param {Array} oversizedElements - Array of oversized element data - * @param {number} pageHeightPx - Page height in pixels - */ - function handleOversizedElements(oversizedElements, pageHeightPx, signal) { - if (!oversizedElements || oversizedElements.length === 0) { - return; - } + // Apply physical scale + element.style.width = `${origWidth * scaleFactor}px`; + element.style.height = `${origHeight * scaleFactor}px`; + if (elementType === 'svg') { + element.style.maxWidth = 'none'; + } + } else { + // For pre, table, blockquote, math, li, etc. + // Use transform: scale combined with physical height and overflow hidden to guarantee no native splits + if (!element.dataset.hasOwnProperty('pdfOriginalHeight')) { + element.dataset.pdfOriginalHeight = element.style.height || ''; + } + if (!element.dataset.hasOwnProperty('pdfOriginalOverflow')) { + element.dataset.pdfOriginalOverflow = element.style.overflow || ''; + } - let scaledCount = 0; - let clampedCount = 0; + element.style.transform = `scale(${scaleFactor})`; + element.style.transformOrigin = 'top left'; - for (const item of oversizedElements) { - throwIfPdfExportAborted(signal); + let origHeight = parseFloat(element.dataset.pdfOriginalClientHeight); + if (isNaN(origHeight)) { + origHeight = element.offsetHeight || element.getBoundingClientRect().height; + element.dataset.pdfOriginalClientHeight = String(origHeight); + } - // Calculate required scale factor - const { scaleFactor, wasClampedToMin } = calculateScaleFactor( - item.height, - pageHeightPx - ); + const scaledHeight = origHeight * scaleFactor; + element.style.height = `${scaledHeight}px`; + element.style.overflow = 'hidden'; + } + } - // Apply scaling to the element - applyGraphicScaling(item.element, scaleFactor, item.type); - scaledCount++; - if (wasClampedToMin) { - clampedCount++; - } - } - logPdfExportDebug('Oversized graphics scaling complete:', { - totalScaled: scaledCount, - clampedToMinimum: clampedCount + function waitForAllImages(container) { + const imgs = Array.from(container.querySelectorAll('img')); + const promises = imgs.map(img => { + if (img.complete) return Promise.resolve(); + return new Promise(resolve => { + img.addEventListener('load', resolve, { once: true }); + img.addEventListener('error', resolve, { once: true }); + }); }); + return Promise.all(promises); } // ============================================ @@ -7460,7 +7781,11 @@ document.addEventListener("DOMContentLoaded", function () { exportPdf.addEventListener("click", async function (event) { event.preventDefault(); - if (activePdfExport) return; + logPdfExportDebug("PDF export button clicked!"); + if (activePdfExport) { + logPdfExportDebug("PDF export already active, ignoring click"); + return; + } const progressState = createPdfProgressState(); activePdfExport = progressState; @@ -7470,20 +7795,27 @@ document.addEventListener("DOMContentLoaded", function () { progressState.overlay.querySelector(".pdf-progress-cancel")?.focus(); try { + logPdfExportDebug("PDF export try block entered. typeof jspdf:", typeof jspdf, "typeof html2canvas:", typeof html2canvas); // PERF-002: Lazy-load PDF libraries on first export if (typeof jspdf === 'undefined' || typeof html2canvas === 'undefined') { + logPdfExportDebug("Lazy loading PDF libraries started..."); updatePdfProgress(progressState, 8, "Loading PDF libraries"); - await runPdfAbortable(progressState, Promise.all([loadScript(CDN.jspdf), loadScript(CDN.html2canvas)])); + await runPdfAbortable(progressState, Promise.all([ + loadScript(CDN.jspdf).then(() => logPdfExportDebug("jspdf load callback fired")), + loadScript(CDN.html2canvas).then(() => logPdfExportDebug("html2canvas load callback fired")) + ])); + logPdfExportDebug("Lazy loading PDF libraries resolved."); throwIfPdfExportAborted(progressState.signal); } - + logPdfExportDebug("Parsing markdown..."); updatePdfProgress(progressState, 15, "Parsing markdown"); await waitForPdfFrame(progressState); const markdown = markdownEditor.value; const html = marked.parse(markdown); const sanitizedHtml = DOMPurify.sanitize(html, { ADD_TAGS: ['mjx-container', 'svg', 'path', 'g', 'marker', 'defs', 'pattern', 'clipPath', 'input'], - ADD_ATTR: ['id', 'class', 'style', 'align', 'viewBox', 'd', 'fill', 'stroke', 'transform', 'marker-end', 'marker-start', 'type', 'checked', 'disabled', 'data-original-code'] + ADD_ATTR: ['id', 'class', 'style', 'align', 'viewBox', 'd', 'fill', 'stroke', 'transform', 'marker-end', 'marker-start', 'type', 'checked', 'disabled', 'data-original-code'], + ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel|blob):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i }); throwIfPdfExportAborted(progressState.signal); @@ -7494,7 +7826,7 @@ document.addEventListener("DOMContentLoaded", function () { tempElement.className = "markdown-body pdf-export"; tempElement.innerHTML = sanitizedHtml; enhanceGitHubAlerts(tempElement); - tempElement.style.padding = "20px"; + tempElement.style.padding = "0px"; tempElement.style.width = "210mm"; tempElement.style.margin = "0 auto"; tempElement.style.fontSize = "14px"; @@ -7522,6 +7854,46 @@ document.addEventListener("DOMContentLoaded", function () { tempElement.querySelectorAll('.mermaid-container.is-loading').forEach(container => { container.classList.remove('is-loading'); }); + + // Convert all rendered Mermaid SVGs inside tempElement to tags with data URI sources + const compiledMermaids = tempElement.querySelectorAll('.mermaid-container'); + compiledMermaids.forEach(container => { + const svgElement = container.querySelector('svg'); + if (svgElement) { + const rect = svgElement.getBoundingClientRect(); + const width = rect.width || svgElement.clientWidth || parseFloat(svgElement.getAttribute('width')) || 600; + const height = rect.height || svgElement.clientHeight || parseFloat(svgElement.getAttribute('height')) || 400; + + const clonedSvg = svgElement.cloneNode(true); + clonedSvg.setAttribute('width', width); + clonedSvg.setAttribute('height', height); + if (!clonedSvg.getAttribute('viewBox')) { + clonedSvg.setAttribute('viewBox', `0 0 ${width} ${height}`); + } + clonedSvg.style.width = `${width}px`; + clonedSvg.style.height = `${height}px`; + + const svgString = new XMLSerializer().serializeToString(clonedSvg); + const svgBase64 = btoa(unescape(encodeURIComponent(svgString))); + + const img = document.createElement('img'); + img.className = 'mermaid-img'; + if (svgElement.id) img.id = svgElement.id + '-img'; + img.src = 'data:image/svg+xml;base64,' + svgBase64; + + img.style.width = `${width}px`; + img.style.height = `${height}px`; + img.style.maxWidth = '100%'; + img.style.display = 'block'; + img.style.margin = '0 auto'; + + img.dataset.originalWidth = String(width); + img.dataset.originalHeight = String(height); + + container.innerHTML = ''; + container.appendChild(img); + } + }); } catch (mermaidError) { if (mermaidError instanceof PdfExportCancelledError) throw mermaidError; console.warn("Mermaid rendering issue:", mermaidError); @@ -7566,15 +7938,20 @@ document.addEventListener("DOMContentLoaded", function () { fitExportElementToContent(tempElement); await waitForPdfFrame(progressState); + // Await loading of all images and fonts (including converted Mermaid base64 images) before cascade sizing runs + updatePdfProgress(progressState, 50, "Loading document assets"); + await runPdfAbortable(progressState, Promise.all([ + waitForAllImages(tempElement), + document.fonts ? document.fonts.ready : Promise.resolve() + ])); + throwIfPdfExportAborted(progressState.signal); + await waitForPdfFrame(progressState); + // Analyze and apply page-breaks for graphics (Story 1.1 + 1.2) updatePdfProgress(progressState, 55, "Optimizing page breaks"); const pageBreakAnalysis = applyPageBreaksWithCascade(tempElement, PAGE_CONFIG, 10, progressState.signal); throwIfPdfExportAborted(progressState.signal); - // Scale oversized graphics that can't fit on a single page (Story 1.3) - if (pageBreakAnalysis.oversizedElements && pageBreakAnalysis.pageHeightPx) { - handleOversizedElements(pageBreakAnalysis.oversizedElements, pageBreakAnalysis.pageHeightPx, progressState.signal); - } await waitForPdfFrame(progressState); const pdfOptions = { @@ -7599,14 +7976,20 @@ document.addEventListener("DOMContentLoaded", function () { allowTaint: false, logging: false, windowWidth: Math.max(PAGE_CONFIG.windowWidth, Math.ceil(tempElement.getBoundingClientRect().width)), - windowHeight: tempElement.scrollHeight + windowHeight: Math.ceil(tempElement.getBoundingClientRect().height) })); await waitForPdfFrame(progressState); throwIfPdfExportAborted(progressState.signal); + console.log(`[PDF DEBUG] canvas.width = ${canvas.width}, canvas.height = ${canvas.height}`); + console.log(`[PDF DEBUG] tempElement.offsetWidth = ${tempElement.offsetWidth}, rect.width = ${tempElement.getBoundingClientRect().width}`); const scaleFactor = canvas.width / contentWidth; + console.log(`[PDF DEBUG] scaleFactor = ${scaleFactor}, PAGE_CONFIG.scale = ${PAGE_CONFIG.scale}, captureScale = ${captureScale}`); const imgHeight = canvas.height / scaleFactor; - const pagesCount = Math.ceil(imgHeight / (pageHeight - margin * 2)); + console.log(`[PDF DEBUG] imgHeight = ${imgHeight}, contentHeight = ${pageHeight - margin * 2}`); + // Introduce a 0.5mm tolerance to prevent rounding errors from creating a trailing blank page + const pagesCount = Math.ceil((imgHeight - 0.5) / (pageHeight - margin * 2)); + console.log(`[PDF DEBUG] pagesCount = ${pagesCount}`); updatePdfProgress(progressState, 76, "Rendering pages"); for (let page = 0; page < pagesCount; page++) { @@ -9255,6 +9638,42 @@ document.addEventListener("DOMContentLoaded", function () { // Run detection detectAndInitLanguage(); + // Intercept all link clicks in the preview pane to open them securely and prevent page navigation + if (markdownPreview) { + markdownPreview.addEventListener('click', function(e) { + const link = e.target.closest('a'); + if (link) { + const href = link.getAttribute('href'); + if (href) { + if (href.startsWith('#')) { + return; // Allow internal anchor navigation + } + + e.preventDefault(); + // Defense-in-depth: check that the URL protocol is safe + let isSafe = false; + try { + const parsed = new URL(href, window.location.href); + isSafe = ['http:', 'https:', 'mailto:', 'tel:', 'blob:'].includes(parsed.protocol); + } catch (err) { + // If URL constructor fails, it might be a relative path without a base, which is safe to resolve + isSafe = !href.trim().toLowerCase().startsWith('javascript:'); + } + + if (isSafe) { + if (typeof Neutralino !== 'undefined') { + Neutralino.os.open(href); + } else { + window.open(href, '_blank', 'noopener,noreferrer'); + } + } else { + console.warn('Blocked opening potentially unsafe URL:', href); + } + } + } + }); + } + // Register Service Worker for offline capabilities if ('serviceWorker' in navigator) { window.addEventListener('load', function() { diff --git a/exported_document.pdf b/exported_document.pdf new file mode 100644 index 00000000..37736e79 Binary files /dev/null and b/exported_document.pdf differ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..51d4ac9d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,75 @@ +{ + "name": "Markdown-Viewer", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "playwright": "^1.60.0" + }, + "devDependencies": { + "@playwright/test": "^1.60.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..04d7b794 --- /dev/null +++ b/package.json @@ -0,0 +1,8 @@ +{ + "dependencies": { + "playwright": "^1.60.0" + }, + "devDependencies": { + "@playwright/test": "^1.60.0" + } +} diff --git a/preview-worker.js b/preview-worker.js index ca193729..4a97c054 100644 --- a/preview-worker.js +++ b/preview-worker.js @@ -350,7 +350,7 @@ function ensureLibraries(urls) { } function isSegmentedPreviewSafe(markdown) { - if (/^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/.test(markdown)) return false; + if (/^\s*---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/.test(markdown)) return false; if (/^\[[^\]\n]+\]:\s+\S+/m.test(markdown)) return false; if (/\[\^[^\]\n]+\]/.test(markdown)) return false; if (/\n:[ \t]+/.test(markdown)) return false; diff --git a/script.js b/script.js index 631398d5..b19f2af0 100644 --- a/script.js +++ b/script.js @@ -453,7 +453,7 @@ document.addEventListener("DOMContentLoaded", function () { function isSegmentedPreviewSafe(markdown) { if (!markdown || markdown.length < PREVIEW_WORKER_THRESHOLD) return false; - if (/^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/.test(markdown)) return false; + if (/^\s*---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/.test(markdown)) return false; if (/^\[[^\]\n]+\]:\s+\S+/m.test(markdown)) return false; if (/\[\^[^\]\n]+\]/.test(markdown)) return false; if (/\n:[ \t]+/.test(markdown)) return false; @@ -1043,7 +1043,7 @@ document.addEventListener("DOMContentLoaded", function () { } function parseFrontmatter(markdown) { - const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---(\r?\n|$)/); + const match = markdown.match(/^\s*---\r?\n([\s\S]*?)\r?\n---(\r?\n|$)/); if (!match) return { frontmatter: null, body: markdown }; try { const data = jsyaml.load(match[1]) || {}; @@ -6538,7 +6538,8 @@ document.addEventListener("DOMContentLoaded", function () { const html = tableHtml + marked.parse(referenceData.cleanedMarkdown); const sanitizedHtml = DOMPurify.sanitize(html, { ADD_TAGS: ['mjx-container', 'input'], - ADD_ATTR: ['id', 'class', 'style', 'align', 'type', 'checked', 'disabled', 'data-original-code'] + ADD_ATTR: ['id', 'class', 'style', 'align', 'type', 'checked', 'disabled', 'data-original-code'], + ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel|blob):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i }); const tempContainer = document.createElement("div"); tempContainer.innerHTML = sanitizedHtml; @@ -6724,10 +6725,15 @@ document.addEventListener("DOMContentLoaded", function () { if (overflow <= 1) return; var styles = window.getComputedStyle(article); + var paddingLeft = parseFloat(styles.paddingLeft) || 0; var paddingRight = parseFloat(styles.paddingRight) || 0; var borderRight = parseFloat(styles.borderRightWidth) || 0; var borderLeft = parseFloat(styles.borderLeftWidth) || 0; - var requiredWidth = Math.ceil(article.scrollWidth + paddingRight + borderLeft + borderRight); + var boxSizing = styles.boxSizing; + + var requiredWidth = boxSizing === 'border-box' + ? Math.ceil(article.scrollWidth + borderLeft + borderRight) + : Math.ceil(article.scrollWidth - paddingLeft - paddingRight); article.style.width = requiredWidth + 'px'; article.style.maxWidth = 'none'; @@ -6794,7 +6800,7 @@ document.addEventListener("DOMContentLoaded", function () { scale: 2 // html2canvas scale factor }; - const PDF_EXPORT_DEBUG = false; + const PDF_EXPORT_DEBUG = true; let activePdfExport = null; class PdfExportCancelledError extends Error { @@ -6943,7 +6949,12 @@ document.addEventListener("DOMContentLoaded", function () { state.cleanedUp = true; if (state.tempElement && state.tempElement.parentNode) { - state.tempElement.parentNode.removeChild(state.tempElement); + if (window.keepTempElementForAudit) { + window.auditedTempElement = state.tempElement; + console.log("Skipped tempElement removal for audit"); + } else { + state.tempElement.parentNode.removeChild(state.tempElement); + } } if (state.overlay && state.overlay.parentNode) { state.overlay.parentNode.removeChild(state.overlay); @@ -6996,8 +7007,8 @@ document.addEventListener("DOMContentLoaded", function () { const boxSizing = window.getComputedStyle(element).boxSizing; const requiredWidth = boxSizing === 'border-box' - ? Math.ceil(element.scrollWidth + paddingRight + borderLeft + borderRight) - : Math.ceil(element.scrollWidth - paddingLeft + paddingRight); + ? Math.ceil(element.scrollWidth + borderLeft + borderRight) + : Math.ceil(element.scrollWidth - paddingLeft - paddingRight); element.style.width = `${requiredWidth}px`; return true; @@ -7012,19 +7023,142 @@ document.addEventListener("DOMContentLoaded", function () { const graphics = []; // Query all targeting elements in precise DOM layout flow order - container.querySelectorAll('img, svg, pre, table').forEach(el => { - let type = 'img'; + container.querySelectorAll('img, svg, pre, table, p, li, h1, h2, h3, h4, h5, h6, blockquote, hr, .math-block, mjx-container[display="true"]').forEach(el => { const tag = el.tagName.toLowerCase(); - if (tag === 'svg') type = 'svg'; + + // Skip any elements nested inside blockquotes to treat blockquotes as atomic containers + if (el.parentElement && el.parentElement.closest('blockquote')) { + return; + } + + // Skip any elements nested inside list items that contain block children (treat list items as atomic) + if (el.parentElement) { + const liAncestor = el.parentElement.closest('li'); + if (liAncestor) { + const hasBlockChildren = liAncestor.querySelector('p, blockquote, pre, table, ul, ol') !== null; + if (hasBlockChildren) { + return; + } + } + } + + let type = ''; + + if (tag === 'img') type = 'img'; + else if (tag === 'svg') { + if (!el.closest('mjx-container, .math-block')) { + type = 'svg'; + } + } else if (tag === 'pre') type = 'pre'; else if (tag === 'table') type = 'table'; + else if (tag === 'hr') type = 'hr'; + else if (tag === 'blockquote') { + type = 'blockquote'; + } + else if (['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tag)) { + type = 'text'; + } else if (tag === 'li') { + // Treat list items with block children as atomic containers, otherwise treat as text + const hasBlockChildren = el.querySelector('p, blockquote, pre, table, ul, ol') !== null; + if (hasBlockChildren) { + type = 'li'; + } else { + type = 'text'; + } + } else if (el.classList.contains('math-block') || tag === 'mjx-container') { + type = 'math'; + } - graphics.push({ element: el, type: type }); + if (type) { + graphics.push({ element: el, type: type }); + } }); return graphics; } + /** + * Calculates the computed line-height of a text element, defaulting based on tag if "normal" + * @param {HTMLElement} element + * @returns {number} The line-height in pixels + */ + function getElementLineHeight(element) { + const style = window.getComputedStyle(element); + const fontSize = parseFloat(style.fontSize) || 14; + let lineHeight = parseFloat(style.lineHeight); + + if (isNaN(lineHeight)) { + const tag = element.tagName.toLowerCase(); + if (tag.startsWith('h')) { + lineHeight = fontSize * 1.25; + } else { + lineHeight = fontSize * 1.5; + } + } else if (lineHeight < 10) { + // Handle unitless line-height (e.g. "1.5") + lineHeight = lineHeight * fontSize; + } + return lineHeight; + } + + /** + * Calculates the shift needed to align a split text element's lines with the page boundary + * @param {Object} item - The split element item + * @param {Array} pageBoundaries - Page break positions + * @returns {number} The shift in pixels + */ + function calculateTextElementShift(item, pageBoundaries) { + const boundaryY = pageBoundaries[item.splitPageIndex]; + if (boundaryY === undefined) return 0; + + const element = item.element; + const style = window.getComputedStyle(element); + + const tag = element.tagName.toLowerCase(); + const isHeading = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tag); + + // Safety buffer (in pixels) to ensure text rendering, glyph ascenders, + // and sub-pixel anti-aliasing are pushed cleanly past the slicing boundary. + const SAFETY_BUFFER = 4; + + // Headings should never be split. Push the entire heading to the next page. + if (isHeading) { + return (boundaryY - item.top) + SAFETY_BUFFER; + } + + const paddingTop = parseFloat(style.paddingTop) || 0; + const borderTop = parseFloat(style.borderTopWidth) || 0; + const paddingBottom = parseFloat(style.paddingBottom) || 0; + const borderBottom = parseFloat(style.borderBottomWidth) || 0; + + const lh = getElementLineHeight(element); + const contentTop = item.top + paddingTop + borderTop; + const contentHeight = item.height - paddingTop - paddingBottom - borderTop - borderBottom; + if (contentHeight <= 0) return 0; + + const numLines = Math.max(1, Math.round(contentHeight / lh)); + + for (let i = 0; i < numLines; i++) { + const lineTop = contentTop + i * lh; + const lineBottom = contentTop + (i + 1) * lh; + + // Check if this line is split by boundaryY (using a small tolerance to prevent float vibrations) + if (lineTop < boundaryY - 0.5 && lineBottom > boundaryY + 0.5) { + // Calculate shift to align the line's top with boundaryY, plus a safety buffer + return (boundaryY - lineTop) + SAFETY_BUFFER; + } + } + + // Fallback: If a short paragraph/list item is split across pages but no single line is cut + // (e.g. boundary falls exactly in padding/margin), push the whole element. + if (item.height <= lh * 3) { + return (boundaryY - item.top) + SAFETY_BUFFER; + } + + return 0; + } + /** * Task 2: Calculates element positions relative to the container * @param {Array} elements - Array of {element, type} objects @@ -7058,9 +7192,6 @@ document.addEventListener("DOMContentLoaded", function () { * @returns {Array} Array of y-coordinates where pages end */ function calculatePageBoundaries(totalHeight, elementWidth, pageConfig) { - // Calculate pixel height per page based on the element's actual width - // This must match how PDF pagination will split the canvas - // The aspect ratio of content area determines page height relative to width const aspectRatio = pageConfig.contentHeight / pageConfig.contentWidth; const pageHeightPx = elementWidth * aspectRatio; @@ -7082,20 +7213,12 @@ document.addEventListener("DOMContentLoaded", function () { * @returns {Array} Array of split elements with additional split info */ function detectSplitElements(elements, pageBoundaries) { - // Handle edge case: empty elements array - if (!elements || elements.length === 0) { - return []; - } - - // Handle edge case: no page boundaries (single page) - if (!pageBoundaries || pageBoundaries.length === 0) { - return []; - } + if (!elements || elements.length === 0) return []; + if (!pageBoundaries || pageBoundaries.length === 0) return []; const splitElements = []; for (const item of elements) { - // Find which page the element starts on let startPage = 0; for (let i = 0; i < pageBoundaries.length; i++) { if (item.top >= pageBoundaries[i]) { @@ -7105,7 +7228,6 @@ document.addEventListener("DOMContentLoaded", function () { } } - // Find which page the element ends on let endPage = 0; for (let i = 0; i < pageBoundaries.length; i++) { if (item.bottom > pageBoundaries[i]) { @@ -7115,9 +7237,7 @@ document.addEventListener("DOMContentLoaded", function () { } } - // Element is split if it spans multiple pages if (endPage > startPage) { - // Calculate overflow amount (how much crosses into next page) const boundaryY = pageBoundaries[startPage] || pageBoundaries[0]; const overflowAmount = item.bottom - boundaryY; @@ -7144,23 +7264,12 @@ document.addEventListener("DOMContentLoaded", function () { try { throwIfPdfExportAborted(signal); - // Step 1: Identify all graphic elements const graphics = identifyGraphicElements(tempElement); - logPdfExportDebug('Step 1 - Graphics found:', graphics.length, graphics.map(g => g.type)); - - // Step 2: Calculate positions for each element const elementsWithPositions = calculateElementPositions(graphics, tempElement); - logPdfExportDebug('Step 2 - Element positions:', elementsWithPositions.map(e => ({ - type: e.type, - top: Math.round(e.top), - height: Math.round(e.height), - bottom: Math.round(e.bottom) - }))); throwIfPdfExportAborted(signal); - // Step 3: Calculate page boundaries using the element's ACTUAL width - const totalHeight = tempElement.scrollHeight; + const totalHeight = Math.ceil(tempElement.getBoundingClientRect().height); const elementWidth = tempElement.offsetWidth; const { boundaries: pageBoundaries, pageHeightPx } = calculatePageBoundaries( totalHeight, @@ -7168,18 +7277,7 @@ document.addEventListener("DOMContentLoaded", function () { PAGE_CONFIG ); - logPdfExportDebug('Step 3 - Page boundaries:', { - elementWidth, - totalHeight, - pageHeightPx: Math.round(pageHeightPx), - boundaries: pageBoundaries.map(b => Math.round(b)) - }); - - // Step 4: Detect split elements const splitElements = detectSplitElements(elementsWithPositions, pageBoundaries); - logPdfExportDebug('Step 4 - Split elements detected:', splitElements.length); - - // Calculate page count const pageCount = pageBoundaries.length + 1; return { @@ -7210,144 +7308,367 @@ document.addEventListener("DOMContentLoaded", function () { // Page-Break Insertion Functions (Story 1.2) // ============================================ - // Threshold for whitespace optimization (30% of page height) - const PAGE_BREAK_THRESHOLD = 0.3; + /** - * Task 3: Categorizes split elements by whether they fit on a single page - * @param {Array} splitElements - Array of split elements from detection - * @param {number} pageHeightPx - Page height in pixels - * @returns {Object} { fittingElements, oversizedElements } + * Resets temporary styles applied to graphics elements back to their original state. + * This is called at the start of each layout iteration in the cascade loop. + * @param {HTMLElement} container - The container element to process */ - function categorizeBySize(splitElements, pageHeightPx) { - const fittingElements = []; - const oversizedElements = []; - - for (const item of splitElements) { - if (item.height <= pageHeightPx) { - fittingElements.push(item); - } else { - oversizedElements.push(item); - } - } + function resetGraphicsStyles(container) { + // Remove all dynamically inserted page-break spacers + container.querySelectorAll('.pdf-page-break-spacer').forEach(el => el.remove()); - return { fittingElements, oversizedElements }; + container.querySelectorAll('[data-pdf-original-margin-top]').forEach(el => { + el.style.marginTop = el.dataset.pdfOriginalMarginTop; + el.removeAttribute('data-pdf-original-margin-top'); + }); + container.querySelectorAll('[data-pdf-original-margin-bottom]').forEach(el => { + el.style.marginBottom = el.dataset.pdfOriginalMarginBottom; + el.removeAttribute('data-pdf-original-margin-bottom'); + }); + container.querySelectorAll('[data-pdf-original-transform]').forEach(el => { + el.style.transform = el.dataset.pdfOriginalTransform; + el.style.transformOrigin = ''; + el.removeAttribute('data-pdf-original-transform'); + }); + container.querySelectorAll('[data-pdf-original-width]').forEach(el => { + el.style.width = el.dataset.pdfOriginalWidth; + el.removeAttribute('data-pdf-original-width'); + }); + container.querySelectorAll('[data-pdf-original-height]').forEach(el => { + el.style.height = el.dataset.pdfOriginalHeight; + el.removeAttribute('data-pdf-original-height'); + }); + container.querySelectorAll('[data-pdf-original-max-width]').forEach(el => { + el.style.maxWidth = el.dataset.pdfOriginalMaxWidth; + el.removeAttribute('data-pdf-original-max-width'); + }); + container.querySelectorAll('[data-pdf-original-font-size]').forEach(el => { + el.style.fontSize = el.dataset.pdfOriginalFontSize; + el.removeAttribute('data-pdf-original-font-size'); + }); + container.querySelectorAll('[data-pdf-original-overflow]').forEach(el => { + el.style.overflow = el.dataset.pdfOriginalOverflow; + el.removeAttribute('data-pdf-original-overflow'); + }); } - /** - * Task 1: Inserts page breaks by adjusting margins for fitting elements - * @param {Array} fittingElements - Elements that fit on a single page - * @param {number} pageHeightPx - Page height in pixels - */ - function insertPageBreaks(fittingElements, pageHeightPx, signal) { - for (const item of fittingElements) { - throwIfPdfExportAborted(signal); - - // Calculate where the current page ends - const currentPageBottom = (item.splitPageIndex + 1) * pageHeightPx; + function mergeSplitTables(container) { + const groupIds = new Set(); + container.querySelectorAll('table[data-split-group-id]').forEach(t => { + if (t.dataset.splitGroupId) { + groupIds.add(t.dataset.splitGroupId); + } + }); - // Calculate remaining space on current page - const remainingSpace = currentPageBottom - item.top; - const remainingRatio = remainingSpace / pageHeightPx; + for (const groupId of groupIds) { + const originalTable = container.querySelector(`table[data-split-group-id="${groupId}"]:not([data-split-part="true"])`); + if (!originalTable) continue; - logPdfExportDebug('Processing split element:', { - type: item.type, - top: Math.round(item.top), - height: Math.round(item.height), - splitPageIndex: item.splitPageIndex, - currentPageBottom: Math.round(currentPageBottom), - remainingSpace: Math.round(remainingSpace), - remainingRatio: remainingRatio.toFixed(2) - }); + const parts = Array.from(container.querySelectorAll(`table[data-split-group-id="${groupId}"][data-split-part="true"]`)); + const tbody = originalTable.tBodies[0] || originalTable.querySelector('tbody') || originalTable; - // Task 4: Whitespace optimization - // If remaining space is more than threshold and element almost fits, skip - // (Will be handled by Story 1.3 scaling instead) - if (remainingRatio > PAGE_BREAK_THRESHOLD) { - const scaledHeight = item.height * 0.9; // 90% scale - if (scaledHeight <= remainingSpace) { - logPdfExportDebug(' -> Skipping (can fit with 90% scaling)'); - continue; + for (const part of parts) { + const partTbody = part.tBodies[0] || part.querySelector('tbody') || part; + const rows = Array.from(partTbody.children).filter(child => child.tagName.toLowerCase() === 'tr'); + for (const row of rows) { + tbody.appendChild(row); } + part.remove(); + } + + const spacers = Array.from(container.querySelectorAll(`div[data-split-group-id="${groupId}"][data-split-spacer="true"]`)); + for (const spacer of spacers) { + spacer.remove(); } - // Calculate margin needed to push element to next page - const marginNeeded = currentPageBottom - item.top + 5; // 5px buffer + originalTable.removeAttribute('data-split-group-id'); + } + } + + function splitTables(container, pageHeightPx) { + mergeSplitTables(container); - logPdfExportDebug(' -> Applying marginTop:', marginNeeded, 'px'); + const tables = Array.from(container.querySelectorAll('table')); + let groupCounter = 0; - // Determine which element to apply margin to - // For SVG elements (Mermaid diagrams), apply to parent container for proper layout - let targetElement = item.element; - if (item.type === 'svg' && item.element.parentElement) { - targetElement = item.element.parentElement; - logPdfExportDebug(' -> Using parent element:', targetElement.tagName, targetElement.className); - } + for (const table of tables) { + if (table.dataset.splitPart === "true") continue; + const tableRect = table.getBoundingClientRect(); + if (tableRect.height <= pageHeightPx) continue; + + const tbody = table.tBodies[0] || table.querySelector('tbody'); + if (!tbody) continue; - // Apply margin to push element to next page - const currentMargin = parseFloat(targetElement.style.marginTop) || 0; - targetElement.style.marginTop = `${currentMargin + marginNeeded}px`; + const rows = Array.from(tbody.children).filter(child => child.tagName.toLowerCase() === 'tr'); + if (rows.length === 0) continue; - logPdfExportDebug(' -> Element after margin:', targetElement.tagName, 'marginTop =', targetElement.style.marginTop); + const groupId = `table-group-${groupCounter++}`; + table.dataset.splitGroupId = groupId; + const containerRect = container.getBoundingClientRect(); + + const rowPositions = rows.map(row => { + const rect = row.getBoundingClientRect(); + return { + row: row, + top: rect.top - containerRect.top, + bottom: rect.bottom - containerRect.top, + height: rect.height + }; + }); + + let currentTable = table; + let currentTbody = tbody; + let accumulatedShift = 0; + + for (let i = 0; i < rowPositions.length; i++) { + const pos = rowPositions[i]; + const shiftedTop = pos.top + accumulatedShift; + const shiftedBottom = pos.bottom + accumulatedShift; + const currentPageIndex = Math.floor(shiftedTop / pageHeightPx); + const nextPageBoundary = (currentPageIndex + 1) * pageHeightPx; + + if (shiftedBottom > nextPageBoundary) { + const spacerHeight = nextPageBoundary - shiftedTop; + const originalThead = table.querySelector('thead'); + const theadHeight = originalThead ? originalThead.getBoundingClientRect().height : 0; + accumulatedShift += spacerHeight + theadHeight; + + const nextTable = table.cloneNode(false); + nextTable.removeAttribute('id'); + nextTable.dataset.splitGroupId = groupId; + nextTable.dataset.splitPart = "true"; + + if (originalThead) nextTable.appendChild(originalThead.cloneNode(true)); + const nextTbody = document.createElement('tbody'); + nextTable.appendChild(nextTbody); + + const spacer = document.createElement('div'); + spacer.className = 'table-page-break-spacer'; + spacer.dataset.splitGroupId = groupId; + spacer.dataset.splitSpacer = "true"; + spacer.style.height = `${spacerHeight}px`; + spacer.style.margin = '0'; + spacer.style.padding = '0'; + spacer.style.border = 'none'; + + currentTable.parentNode.insertBefore(spacer, currentTable.nextSibling); + spacer.parentNode.insertBefore(nextTable, spacer.nextSibling); + currentTable = nextTable; + currentTbody = nextTbody; + } + + if (currentTable !== table) currentTbody.appendChild(pos.row); + } } } - /** - * Task 2: Applies page breaks with cascading adjustment handling - * @param {HTMLElement} tempElement - The rendered content container - * @param {Object} pageConfig - Page configuration object (unused, kept for API compatibility) - * @param {number} maxIterations - Maximum iterations to prevent infinite loops - * @returns {Object} Final analysis result - */ function applyPageBreaksWithCascade(tempElement, pageConfig, maxIterations = 10, signal) { let iteration = 0; let analysis; - let previousSplitCount = -1; + + const elementWidth = tempElement.offsetWidth; + const aspectRatio = pageConfig.contentHeight / pageConfig.contentWidth; + const pageHeightPx = elementWidth * aspectRatio; + + const lastAdjustments = new Map(); // Store {margin, scale} for each element do { throwIfPdfExportAborted(signal); - // Re-analyze after each adjustment - analysis = analyzeGraphicsForPageBreaks(tempElement, signal); + // Reset graphics element styles applied in previous iterations + resetGraphicsStyles(tempElement); - // Use pageHeightPx from analysis (calculated from actual element width) - const pageHeightPx = analysis.pageHeightPx; + // Split tables at page breaks dynamically using calculated pageHeightPx + splitTables(tempElement, pageHeightPx); - // Categorize elements by size - const { fittingElements, oversizedElements } = categorizeBySize( - analysis.splitElements, - pageHeightPx + // We must get ALL target elements in document order + const graphics = identifyGraphicElements(tempElement); + const elementsWithPositions = calculateElementPositions(graphics, tempElement); + + // Get page boundaries based on current height + const totalHeight = Math.ceil(tempElement.getBoundingClientRect().height); + const { boundaries: pageBoundaries, pageHeightPx: pageHeightPxFromAnalysis } = calculatePageBoundaries( + totalHeight, + elementWidth, + pageConfig ); - // Store oversized elements for Story 1.3 - analysis.oversizedElements = oversizedElements; + let adjustmentsMade = false; + let accumulatedShift = 0; + const currentIterationAdjustments = new Map(); - // If no fitting elements need adjustment, we're done - if (fittingElements.length === 0) { - break; + for (const item of elementsWithPositions) { + throwIfPdfExportAborted(signal); + + const currentTop = item.top + accumulatedShift; + const currentBottom = currentTop + item.height; + + let targetMargin = 0; + let targetScale = 1.0; + + // 1. Heading Keep-With-Next Rule (must run for all headings regardless of split) + const tag = item.element.tagName.toLowerCase(); + const isHeading = item.type === 'text' && ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tag); + + if (isHeading) { + let nextBoundaryY = null; + for (const boundary of pageBoundaries) { + if (currentTop < boundary) { + nextBoundaryY = boundary; + break; + } + } + if (nextBoundaryY !== null) { + const distanceToBoundary = nextBoundaryY - currentTop; + if (distanceToBoundary < 70) { + targetMargin = distanceToBoundary + 4; // Push heading entirely to next page + } + } + } + + // 2. If not already pushed by Keep-With-Next, perform standard page-split calculations + if (targetMargin === 0) { + // Check if this element crosses any page boundary or starts extremely close to it (sub-pixel safety) + let splitPageIndex = -1; + for (let i = 0; i < pageBoundaries.length; i++) { + if (currentTop < pageBoundaries[i] + 12 && currentBottom > pageBoundaries[i]) { + splitPageIndex = i; + break; + } + } + + if (splitPageIndex !== -1) { + const boundaryY = pageBoundaries[splitPageIndex]; + const remainingSpace = boundaryY - currentTop; + + if (item.type === 'text') { + const shiftedItem = { ...item, top: currentTop, splitPageIndex: splitPageIndex }; + const shift = calculateTextElementShift(shiftedItem, pageBoundaries); + if (shift > 0.5) { + targetMargin = shift; + } + } else { + // Graphic element splitting (with larger buffer to ensure complete clearance) + const buffer = 15; + const scaleNeeded = (remainingSpace - buffer) / item.height; + const remainingSpacePercent = remainingSpace / pageHeightPxFromAnalysis; + + const isTextContainer = ['blockquote', 'li', 'table', 'pre', 'math'].includes(item.type); + + // Fit on current page by scaling if it's an image/svg and space/scale are acceptable. + // Otherwise, always push text/block containers to next page to prevent transform-scaling bugs. + if (!isTextContainer && remainingSpacePercent >= 0.20 && scaleNeeded >= 0.6) { + targetScale = Math.min(1.0, scaleNeeded); + } else { + // Push to next page + const marginNeeded = boundaryY - currentTop + buffer; + targetMargin = marginNeeded; + + // Check if it fits the next page height after being pushed (Rule 3 Case C) + const newTop = currentTop + marginNeeded; + const newBottom = newTop + item.height; + const nextBoundaryY = pageBoundaries[splitPageIndex + 1] || (boundaryY + pageHeightPxFromAnalysis); + if (newBottom > nextBoundaryY) { + const scaleToFitPage = (pageHeightPxFromAnalysis - 20) / item.height; + targetScale = Math.max(0.5, Math.min(1.0, scaleToFitPage)); + } + } + } + } else { + // Element is not split. But graphic elements taller than a page must still scale to fit! + if (item.type !== 'text' && item.height > pageHeightPxFromAnalysis) { + const scaleToFitPage = (pageHeightPxFromAnalysis - 20) / item.height; + targetScale = Math.max(0.5, Math.min(1.0, scaleToFitPage)); + } + } + } + + // Check if this calculated adjustment is different from the previous iteration + const prevAdjustment = lastAdjustments.get(item.element) || { margin: 0, scale: 1.0 }; + if (Math.abs(targetMargin - prevAdjustment.margin) > 0.1 || Math.abs(targetScale - prevAdjustment.scale) > 0.001) { + adjustmentsMade = true; + } + + currentIterationAdjustments.set(item.element, { margin: targetMargin, scale: targetScale }); + + // Apply style adjustments to the DOM + if (targetMargin > 0) { + let targetElement = item.element; + + // Redirect inline image or svg margins to their parent block element if nested + if (item.type === 'svg' && item.element.parentElement) { + const parent = item.element.parentElement; + if (['p', 'li', 'blockquote'].includes(parent.tagName.toLowerCase())) { + targetElement = parent; + } else { + targetElement = parent; + } + } else if (item.type === 'img' && item.element.parentElement) { + const parent = item.element.parentElement; + if (item.element.classList.contains('mermaid-img')) { + if (parent.parentElement && parent.parentElement.classList.contains('mermaid-container')) { + targetElement = parent.parentElement; + } else { + targetElement = parent; + } + } else if (['p', 'li', 'blockquote'].includes(parent.tagName.toLowerCase())) { + targetElement = parent; + } + } + + // If target is a list item, apply marginTop directly to avoid invalid HTML / collapsed spacers + if (targetElement.tagName.toLowerCase() === 'li') { + if (!targetElement.dataset.hasOwnProperty('pdfOriginalMarginTop')) { + targetElement.dataset.pdfOriginalMarginTop = targetElement.style.marginTop || ''; + } + targetElement.style.marginTop = `${targetMargin}px`; + } else { + // Create a physical spacer element to avoid margin collapse issues entirely + const spacer = document.createElement('div'); + spacer.className = 'pdf-page-break-spacer'; + spacer.style.height = `${targetMargin}px`; + spacer.style.margin = '0'; + spacer.style.padding = '0'; + spacer.style.border = 'none'; + spacer.style.display = 'block'; + + targetElement.parentNode.insertBefore(spacer, targetElement); + } + accumulatedShift += targetMargin; + } + + if (targetScale < 1.0) { + applyGraphicScaling(item.element, targetScale, item.type); + const heightSaved = item.height * (1.0 - targetScale); + accumulatedShift -= heightSaved; + } + } + + // Copy current adjustments to lastAdjustments + lastAdjustments.clear(); + for (const [el, adj] of currentIterationAdjustments) { + lastAdjustments.set(el, adj); } - // Check if we're making progress (prevent infinite loops) - if (fittingElements.length === previousSplitCount) { - console.warn('Page-break adjustment not making progress, stopping'); + if (!adjustmentsMade) { break; } - previousSplitCount = fittingElements.length; - // Apply page breaks to fitting elements - insertPageBreaks(fittingElements, pageHeightPx, signal); iteration++; - } while (iteration < maxIterations); if (iteration >= maxIterations) { console.warn('Page-break stabilization reached max iterations:', maxIterations); } + // Return the final page boundaries and height analysis for the export flow + analysis = analyzeGraphicsForPageBreaks(tempElement, signal); + logPdfExportDebug('Page-break cascade complete:', { iterations: iteration, - finalSplitCount: analysis.splitElements.length, - oversizedCount: analysis.oversizedElements ? analysis.oversizedElements.length : 0 + finalSplitCount: analysis.splitElements.length }); return analysis; @@ -7361,22 +7682,13 @@ document.addEventListener("DOMContentLoaded", function () { // Oversized Graphics Scaling Functions (Story 1.3) // ============================================ - // Minimum scale factor to maintain readability (50%) const MIN_SCALE_FACTOR = 0.5; - /** - * Task 1 & 2: Calculates scale factor with minimum enforcement - * @param {number} elementHeight - Original height of element in pixels - * @param {number} availableHeight - Available page height in pixels - * @param {number} buffer - Small buffer to prevent edge overflow - * @returns {Object} { scaleFactor, wasClampedToMin } - */ function calculateScaleFactor(elementHeight, availableHeight, buffer = 5) { const targetHeight = availableHeight - buffer; let scaleFactor = targetHeight / elementHeight; let wasClampedToMin = false; - // Enforce minimum scale for readability if (scaleFactor < MIN_SCALE_FACTOR) { console.warn( `Warning: Large graphic requires ${(scaleFactor * 100).toFixed(0)}% scaling. ` + @@ -7389,69 +7701,78 @@ document.addEventListener("DOMContentLoaded", function () { return { scaleFactor, wasClampedToMin }; } - /** - * Task 3: Applies CSS transform scaling to an element - * @param {HTMLElement} element - The element to scale - * @param {number} scaleFactor - Scale factor (0.5 = 50%) - * @param {string} elementType - Type of element (svg, pre, img, table) - */ function applyGraphicScaling(element, scaleFactor, elementType) { - // Get original dimensions before transform - const originalHeight = element.offsetHeight; - - // Task 4: Handle SVG elements (Mermaid diagrams) - if (elementType === 'svg') { - // Remove max-width constraint that may interfere - element.style.maxWidth = 'none'; + if (!element.dataset.hasOwnProperty('pdfOriginalTransform')) { + element.dataset.pdfOriginalTransform = element.style.transform || ''; + } + if (!element.dataset.hasOwnProperty('pdfOriginalMarginBottom')) { + element.dataset.pdfOriginalMarginBottom = element.style.marginBottom || ''; } - // Apply CSS transform - element.style.transform = `scale(${scaleFactor})`; - element.style.transformOrigin = 'top left'; + if (elementType === 'svg' || elementType === 'img') { + if (!element.dataset.hasOwnProperty('pdfOriginalWidth')) { + element.dataset.pdfOriginalWidth = element.style.width || ''; + } + if (!element.dataset.hasOwnProperty('pdfOriginalHeight')) { + element.dataset.pdfOriginalHeight = element.style.height || ''; + } + if (!element.dataset.hasOwnProperty('pdfOriginalMaxWidth')) { + element.dataset.pdfOriginalMaxWidth = element.style.maxWidth || ''; + } - // Calculate margin adjustment to collapse visual space - const scaledHeight = originalHeight * scaleFactor; - const marginAdjustment = originalHeight - scaledHeight; + let origWidth = parseFloat(element.dataset.pdfOriginalClientWidth); + let origHeight = parseFloat(element.dataset.pdfOriginalClientHeight); - // Apply negative margin to pull subsequent content up - element.style.marginBottom = `-${marginAdjustment}px`; - } + if (isNaN(origWidth) || isNaN(origHeight)) { + origWidth = element.clientWidth || element.getBoundingClientRect().width; + origHeight = element.clientHeight || element.getBoundingClientRect().height; + element.dataset.pdfOriginalClientWidth = String(origWidth); + element.dataset.pdfOriginalClientHeight = String(origHeight); + } - /** - * Task 6: Handles all oversized elements by applying appropriate scaling - * @param {Array} oversizedElements - Array of oversized element data - * @param {number} pageHeightPx - Page height in pixels - */ - function handleOversizedElements(oversizedElements, pageHeightPx, signal) { - if (!oversizedElements || oversizedElements.length === 0) { - return; - } + // Apply physical scale + element.style.width = `${origWidth * scaleFactor}px`; + element.style.height = `${origHeight * scaleFactor}px`; + if (elementType === 'svg') { + element.style.maxWidth = 'none'; + } + } else { + // For pre, table, blockquote, math, li, etc. + // Use transform: scale combined with physical height and overflow hidden to guarantee no native splits + if (!element.dataset.hasOwnProperty('pdfOriginalHeight')) { + element.dataset.pdfOriginalHeight = element.style.height || ''; + } + if (!element.dataset.hasOwnProperty('pdfOriginalOverflow')) { + element.dataset.pdfOriginalOverflow = element.style.overflow || ''; + } - let scaledCount = 0; - let clampedCount = 0; + element.style.transform = `scale(${scaleFactor})`; + element.style.transformOrigin = 'top left'; - for (const item of oversizedElements) { - throwIfPdfExportAborted(signal); + let origHeight = parseFloat(element.dataset.pdfOriginalClientHeight); + if (isNaN(origHeight)) { + origHeight = element.offsetHeight || element.getBoundingClientRect().height; + element.dataset.pdfOriginalClientHeight = String(origHeight); + } - // Calculate required scale factor - const { scaleFactor, wasClampedToMin } = calculateScaleFactor( - item.height, - pageHeightPx - ); + const scaledHeight = origHeight * scaleFactor; + element.style.height = `${scaledHeight}px`; + element.style.overflow = 'hidden'; + } + } - // Apply scaling to the element - applyGraphicScaling(item.element, scaleFactor, item.type); - scaledCount++; - if (wasClampedToMin) { - clampedCount++; - } - } - logPdfExportDebug('Oversized graphics scaling complete:', { - totalScaled: scaledCount, - clampedToMinimum: clampedCount + function waitForAllImages(container) { + const imgs = Array.from(container.querySelectorAll('img')); + const promises = imgs.map(img => { + if (img.complete) return Promise.resolve(); + return new Promise(resolve => { + img.addEventListener('load', resolve, { once: true }); + img.addEventListener('error', resolve, { once: true }); + }); }); + return Promise.all(promises); } // ============================================ @@ -7460,7 +7781,11 @@ document.addEventListener("DOMContentLoaded", function () { exportPdf.addEventListener("click", async function (event) { event.preventDefault(); - if (activePdfExport) return; + logPdfExportDebug("PDF export button clicked!"); + if (activePdfExport) { + logPdfExportDebug("PDF export already active, ignoring click"); + return; + } const progressState = createPdfProgressState(); activePdfExport = progressState; @@ -7470,20 +7795,27 @@ document.addEventListener("DOMContentLoaded", function () { progressState.overlay.querySelector(".pdf-progress-cancel")?.focus(); try { + logPdfExportDebug("PDF export try block entered. typeof jspdf:", typeof jspdf, "typeof html2canvas:", typeof html2canvas); // PERF-002: Lazy-load PDF libraries on first export if (typeof jspdf === 'undefined' || typeof html2canvas === 'undefined') { + logPdfExportDebug("Lazy loading PDF libraries started..."); updatePdfProgress(progressState, 8, "Loading PDF libraries"); - await runPdfAbortable(progressState, Promise.all([loadScript(CDN.jspdf), loadScript(CDN.html2canvas)])); + await runPdfAbortable(progressState, Promise.all([ + loadScript(CDN.jspdf).then(() => logPdfExportDebug("jspdf load callback fired")), + loadScript(CDN.html2canvas).then(() => logPdfExportDebug("html2canvas load callback fired")) + ])); + logPdfExportDebug("Lazy loading PDF libraries resolved."); throwIfPdfExportAborted(progressState.signal); } - + logPdfExportDebug("Parsing markdown..."); updatePdfProgress(progressState, 15, "Parsing markdown"); await waitForPdfFrame(progressState); const markdown = markdownEditor.value; const html = marked.parse(markdown); const sanitizedHtml = DOMPurify.sanitize(html, { ADD_TAGS: ['mjx-container', 'svg', 'path', 'g', 'marker', 'defs', 'pattern', 'clipPath', 'input'], - ADD_ATTR: ['id', 'class', 'style', 'align', 'viewBox', 'd', 'fill', 'stroke', 'transform', 'marker-end', 'marker-start', 'type', 'checked', 'disabled', 'data-original-code'] + ADD_ATTR: ['id', 'class', 'style', 'align', 'viewBox', 'd', 'fill', 'stroke', 'transform', 'marker-end', 'marker-start', 'type', 'checked', 'disabled', 'data-original-code'], + ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel|blob):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i }); throwIfPdfExportAborted(progressState.signal); @@ -7494,7 +7826,7 @@ document.addEventListener("DOMContentLoaded", function () { tempElement.className = "markdown-body pdf-export"; tempElement.innerHTML = sanitizedHtml; enhanceGitHubAlerts(tempElement); - tempElement.style.padding = "20px"; + tempElement.style.padding = "0px"; tempElement.style.width = "210mm"; tempElement.style.margin = "0 auto"; tempElement.style.fontSize = "14px"; @@ -7522,6 +7854,46 @@ document.addEventListener("DOMContentLoaded", function () { tempElement.querySelectorAll('.mermaid-container.is-loading').forEach(container => { container.classList.remove('is-loading'); }); + + // Convert all rendered Mermaid SVGs inside tempElement to tags with data URI sources + const compiledMermaids = tempElement.querySelectorAll('.mermaid-container'); + compiledMermaids.forEach(container => { + const svgElement = container.querySelector('svg'); + if (svgElement) { + const rect = svgElement.getBoundingClientRect(); + const width = rect.width || svgElement.clientWidth || parseFloat(svgElement.getAttribute('width')) || 600; + const height = rect.height || svgElement.clientHeight || parseFloat(svgElement.getAttribute('height')) || 400; + + const clonedSvg = svgElement.cloneNode(true); + clonedSvg.setAttribute('width', width); + clonedSvg.setAttribute('height', height); + if (!clonedSvg.getAttribute('viewBox')) { + clonedSvg.setAttribute('viewBox', `0 0 ${width} ${height}`); + } + clonedSvg.style.width = `${width}px`; + clonedSvg.style.height = `${height}px`; + + const svgString = new XMLSerializer().serializeToString(clonedSvg); + const svgBase64 = btoa(unescape(encodeURIComponent(svgString))); + + const img = document.createElement('img'); + img.className = 'mermaid-img'; + if (svgElement.id) img.id = svgElement.id + '-img'; + img.src = 'data:image/svg+xml;base64,' + svgBase64; + + img.style.width = `${width}px`; + img.style.height = `${height}px`; + img.style.maxWidth = '100%'; + img.style.display = 'block'; + img.style.margin = '0 auto'; + + img.dataset.originalWidth = String(width); + img.dataset.originalHeight = String(height); + + container.innerHTML = ''; + container.appendChild(img); + } + }); } catch (mermaidError) { if (mermaidError instanceof PdfExportCancelledError) throw mermaidError; console.warn("Mermaid rendering issue:", mermaidError); @@ -7566,15 +7938,20 @@ document.addEventListener("DOMContentLoaded", function () { fitExportElementToContent(tempElement); await waitForPdfFrame(progressState); + // Await loading of all images and fonts (including converted Mermaid base64 images) before cascade sizing runs + updatePdfProgress(progressState, 50, "Loading document assets"); + await runPdfAbortable(progressState, Promise.all([ + waitForAllImages(tempElement), + document.fonts ? document.fonts.ready : Promise.resolve() + ])); + throwIfPdfExportAborted(progressState.signal); + await waitForPdfFrame(progressState); + // Analyze and apply page-breaks for graphics (Story 1.1 + 1.2) updatePdfProgress(progressState, 55, "Optimizing page breaks"); const pageBreakAnalysis = applyPageBreaksWithCascade(tempElement, PAGE_CONFIG, 10, progressState.signal); throwIfPdfExportAborted(progressState.signal); - // Scale oversized graphics that can't fit on a single page (Story 1.3) - if (pageBreakAnalysis.oversizedElements && pageBreakAnalysis.pageHeightPx) { - handleOversizedElements(pageBreakAnalysis.oversizedElements, pageBreakAnalysis.pageHeightPx, progressState.signal); - } await waitForPdfFrame(progressState); const pdfOptions = { @@ -7599,14 +7976,20 @@ document.addEventListener("DOMContentLoaded", function () { allowTaint: false, logging: false, windowWidth: Math.max(PAGE_CONFIG.windowWidth, Math.ceil(tempElement.getBoundingClientRect().width)), - windowHeight: tempElement.scrollHeight + windowHeight: Math.ceil(tempElement.getBoundingClientRect().height) })); await waitForPdfFrame(progressState); throwIfPdfExportAborted(progressState.signal); + console.log(`[PDF DEBUG] canvas.width = ${canvas.width}, canvas.height = ${canvas.height}`); + console.log(`[PDF DEBUG] tempElement.offsetWidth = ${tempElement.offsetWidth}, rect.width = ${tempElement.getBoundingClientRect().width}`); const scaleFactor = canvas.width / contentWidth; + console.log(`[PDF DEBUG] scaleFactor = ${scaleFactor}, PAGE_CONFIG.scale = ${PAGE_CONFIG.scale}, captureScale = ${captureScale}`); const imgHeight = canvas.height / scaleFactor; - const pagesCount = Math.ceil(imgHeight / (pageHeight - margin * 2)); + console.log(`[PDF DEBUG] imgHeight = ${imgHeight}, contentHeight = ${pageHeight - margin * 2}`); + // Introduce a 0.5mm tolerance to prevent rounding errors from creating a trailing blank page + const pagesCount = Math.ceil((imgHeight - 0.5) / (pageHeight - margin * 2)); + console.log(`[PDF DEBUG] pagesCount = ${pagesCount}`); updatePdfProgress(progressState, 76, "Rendering pages"); for (let page = 0; page < pagesCount; page++) { @@ -9255,6 +9638,42 @@ document.addEventListener("DOMContentLoaded", function () { // Run detection detectAndInitLanguage(); + // Intercept all link clicks in the preview pane to open them securely and prevent page navigation + if (markdownPreview) { + markdownPreview.addEventListener('click', function(e) { + const link = e.target.closest('a'); + if (link) { + const href = link.getAttribute('href'); + if (href) { + if (href.startsWith('#')) { + return; // Allow internal anchor navigation + } + + e.preventDefault(); + // Defense-in-depth: check that the URL protocol is safe + let isSafe = false; + try { + const parsed = new URL(href, window.location.href); + isSafe = ['http:', 'https:', 'mailto:', 'tel:', 'blob:'].includes(parsed.protocol); + } catch (err) { + // If URL constructor fails, it might be a relative path without a base, which is safe to resolve + isSafe = !href.trim().toLowerCase().startsWith('javascript:'); + } + + if (isSafe) { + if (typeof Neutralino !== 'undefined') { + Neutralino.os.open(href); + } else { + window.open(href, '_blank', 'noopener,noreferrer'); + } + } else { + console.warn('Blocked opening potentially unsafe URL:', href); + } + } + } + }); + } + // Register Service Worker for offline capabilities if ('serviceWorker' in navigator) { window.addEventListener('load', function() { diff --git a/test_breaks.js b/test_breaks.js new file mode 100644 index 00000000..6c494080 --- /dev/null +++ b/test_breaks.js @@ -0,0 +1,181 @@ +const { chromium } = require('playwright'); +const fs = require('fs'); +const path = require('path'); + +async function runAudit() { + console.log("Launching Chromium..."); + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(); + + page.on('console', msg => { + console.log(`BROWSER LOG: ${msg.text()}`); + }); + + page.on('pageerror', err => { + console.error(`BROWSER ERROR: ${err.message}`); + }); + + const indexPath = 'file:///' + path.resolve('index.html').replace(/\\/g, '/'); + console.log(`Navigating to ${indexPath}...`); + await page.goto(indexPath); + + // Wait for the editor to load + await page.waitForSelector('#markdown-editor'); + + // Load the test markdown document + const mdContent = fs.readFileSync('C:/Users/User/.gemini/antigravity/scratch/test_user_issues.md', 'utf8'); + console.log("Setting editor value..."); + + await page.evaluate((content) => { + const editor = document.getElementById('markdown-editor'); + editor.value = content; + editor.dispatchEvent(new Event('input', { bubbles: true })); + editor.dispatchEvent(new Event('change', { bubbles: true })); + }, mdContent); + + // Wait for rendering + console.log("Waiting for editor preview rendering..."); + await page.waitForTimeout(4000); + + // Enable the audit hook in the browser + console.log("Setting keepTempElementForAudit flag..."); + await page.evaluate(() => { + window.keepTempElementForAudit = true; + }); + + console.log("Triggering PDF export..."); + await page.click('#exportDropdown'); + + // Start the export and wait for the file saving download event + const [download] = await Promise.all([ + page.waitForEvent('download', { timeout: 60000 }), + page.click('#export-pdf') + ]); + + console.log("Export complete. Auditing DOM layout bounds of window.auditedTempElement..."); + const auditResults = await page.evaluate(() => { + const tempElement = window.auditedTempElement; + if (!tempElement) { + return { error: "window.auditedTempElement not found" }; + } + + const containerWidth = tempElement.offsetWidth; + // A4 aspect ratio height: contentHeight / contentWidth = 267 / 180 = 1.4833 + // Wait, let's read the page boundaries used by the actual code! + // Since page boundaries are stored during the run, wait, let's calculate them + const pageHeightPx = containerWidth * (267 / 180); + const containerHeight = tempElement.getBoundingClientRect().height; + + // Calculate page boundaries + const pageBoundaries = []; + let currentY = pageHeightPx; + while (currentY < containerHeight) { + pageBoundaries.push(currentY); + currentY += pageHeightPx; + } + + const containerRect = tempElement.getBoundingClientRect(); + + // Find all target graphic elements and headings + const elements = []; + tempElement.querySelectorAll('img, svg, pre, table, p, li, h1, h2, h3, h4, h5, h6, blockquote, hr, .math-block, mjx-container[display="true"]').forEach(el => { + const tag = el.tagName.toLowerCase(); + let type = ''; + if (tag === 'img') type = 'img'; + else if (tag === 'svg') { + if (!el.closest('mjx-container, .math-block')) { + type = 'svg'; + } + } + else if (tag === 'pre') type = 'pre'; + else if (tag === 'table') type = 'table'; + else if (tag === 'hr') type = 'hr'; + else if (['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tag)) type = 'text'; + else if (tag === 'blockquote') { + type = 'blockquote'; + } + else if (tag === 'li') { + const hasBlockChildren = el.querySelector('p, blockquote, pre, table, ul, ol') !== null; + if (hasBlockChildren) { + type = 'li'; + } else { + type = 'text'; + } + } else if (el.classList.contains('math-block') || tag === 'mjx-container') { + type = 'math'; + } + + if (type) { + const rect = el.getBoundingClientRect(); + const top = rect.top - containerRect.top; + const height = rect.height; + const bottom = top + height; + + // Check if it crosses any boundary + let splitByIndex = -1; + for (let i = 0; i < pageBoundaries.length; i++) { + if (top < pageBoundaries[i] && bottom > pageBoundaries[i]) { + splitByIndex = i; + break; + } + } + + elements.push({ + tag: tag, + className: el.className, + id: el.id, + type: type, + top: top, + height: height, + bottom: bottom, + splitPageIndex: splitByIndex, + marginTop: el.style.marginTop || '', + transform: el.style.transform || '', + fontSize: el.style.fontSize || '', + parentTag: el.parentElement ? el.parentElement.tagName.toLowerCase() : '', + parentClass: el.parentElement ? el.parentElement.className : '', + text: el.innerText ? el.innerText.substring(0, 40).replace(/\n/g, ' ') : '' + }); + } + }); + + return { + containerWidth: containerWidth, + containerHeight: containerRect.height, + pageHeightPx: pageHeightPx, + pageBoundaries: pageBoundaries, + elements: elements + }; + }); + + console.log("Saving audit results to C:/Users/User/.gemini/antigravity/scratch/audit_results.json"); + fs.writeFileSync('C:/Users/User/.gemini/antigravity/scratch/audit_results.json', JSON.stringify(auditResults, null, 2)); + + console.log("Analyzing split elements..."); + if (auditResults.error) { + console.error("ERROR during audit:", auditResults.error); + } else { + const splits = auditResults.elements.filter(el => el.splitPageIndex !== -1); + if (splits.length > 0) { + console.log(`WARNING: Found ${splits.length} split elements crossing page boundaries!`); + splits.forEach(el => { + console.log(`- Split Element: <${el.tag} class="${el.className}" id="${el.id}"> of type "${el.type}" crossing Page Boundary ${el.splitPageIndex + 1} (Y=${auditResults.pageBoundaries[el.splitPageIndex].toFixed(1)}px)`); + console.log(` Top: ${el.top.toFixed(1)}px, Height: ${el.height.toFixed(1)}px, Bottom: ${el.bottom.toFixed(1)}px`); + console.log(` Content: "${el.text}"`); + }); + } else { + console.log("SUCCESS: No elements are split across page boundaries!"); + } + } + + // Cleanup downloaded file + const downloadPath = path.join(__dirname, 'temp_exported.pdf'); + await download.saveAs(downloadPath); + fs.unlinkSync(downloadPath); + + await browser.close(); +} + +runAudit().catch(err => { + console.error("Audit script failed:", err); +}); diff --git a/test_pdf.js b/test_pdf.js new file mode 100644 index 00000000..82711ab8 --- /dev/null +++ b/test_pdf.js @@ -0,0 +1,54 @@ +const { chromium } = require('playwright'); +const fs = require('fs'); +const path = require('path'); + +(async () => { + console.log("Launching browser..."); + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(); + + // Handle page console logs + page.on('console', msg => { + console.log(`BROWSER LOG: ${msg.text()}`); + }); + + page.on('pageerror', err => { + console.error(`BROWSER ERROR: ${err.message}`); + }); + + const indexPath = 'file:///' + path.resolve('c:/Users/User/Desktop/Markdown-Viewer/index.html').replace(/\\/g, '/'); + console.log(`Navigating to ${indexPath}...`); + await page.goto(indexPath); + + // Wait for the editor to load + await page.waitForSelector('#markdown-editor'); + + // Load the test markdown document + const mdContent = fs.readFileSync('C:/Users/User/.gemini/antigravity/scratch/document_166.md', 'utf8'); + console.log("Setting editor value..."); + + // Set editor value and trigger change/input events + await page.evaluate((content) => { + const editor = document.getElementById('markdown-editor'); + editor.value = content; + editor.dispatchEvent(new Event('input', { bubbles: true })); + editor.dispatchEvent(new Event('change', { bubbles: true })); + }, mdContent); + + // Wait for rendering to complete (including MathJax and Mermaid) + console.log("Waiting for MathJax/Mermaid rendering..."); + await page.waitForTimeout(4000); + + // Let's click the PDF export button and listen for the download + console.log("Clicking PDF export button..."); + const [download] = await Promise.all([ + page.waitForEvent('download', { timeout: 30000 }), + page.click('#export-pdf') + ]); + + const downloadPath = path.join(__dirname, 'exported_document.pdf'); + await download.saveAs(downloadPath); + console.log(`PDF saved to ${downloadPath}`); + + await browser.close(); +})(); diff --git a/test_pdf.spec.js b/test_pdf.spec.js new file mode 100644 index 00000000..a8cbdec2 --- /dev/null +++ b/test_pdf.spec.js @@ -0,0 +1,84 @@ +const { test, expect } = require('@playwright/test'); +const fs = require('fs'); +const path = require('path'); + +test('PDF Page Breaking Verification', async ({ page }) => { + test.setTimeout(120000); + // Handle page console logs + page.on('console', msg => { + console.log(`BROWSER LOG: ${msg.text()}`); + }); + + page.on('pageerror', err => { + console.error(`BROWSER ERROR: ${err.message}`); + }); + + page.on('request', request => { + console.log(`REQ: ${request.method()} ${request.url()}`); + }); + page.on('requestfailed', request => { + console.log(`REQ FAIL: ${request.url()} - ${request.failure()?.errorText}`); + }); + page.on('response', response => { + console.log(`RES: ${response.status()} ${response.url()}`); + }); + + const indexPath = 'file:///' + path.resolve('c:/Users/User/Desktop/Markdown-Viewer/index.html').replace(/\\/g, '/'); + console.log(`Navigating to ${indexPath}...`); + await page.goto(indexPath); + + // Wait for the editor to load + await page.waitForSelector('#markdown-editor'); + + // Load the test markdown document + const mdContent = fs.readFileSync('C:/Users/User/.gemini/antigravity/scratch/document_166.md', 'utf8'); + console.log("Setting editor value..."); + + // Set editor value and trigger change/input events + await page.evaluate((content) => { + const editor = document.getElementById('markdown-editor'); + editor.value = content; + editor.dispatchEvent(new Event('input', { bubbles: true })); + editor.dispatchEvent(new Event('change', { bubbles: true })); + }, mdContent); + + // Wait for rendering to complete (including MathJax and Mermaid) + await page.waitForTimeout(4000); + + // Analyze preview element height and elements + const analysis = await page.evaluate(() => { + // Let's run a test query of elements + const preview = document.getElementById('markdown-preview'); + const elements = []; + preview.querySelectorAll('img, svg, pre, table, p, li, h1, h2, h3, h4, h5, h6, blockquote, hr, .math-block, mjx-container').forEach(el => { + elements.push({ + tag: el.tagName, + text: el.innerText ? el.innerText.substring(0, 30) : '', + height: el.getBoundingClientRect().height, + top: el.getBoundingClientRect().top + }); + }); + return { + scrollHeight: preview.scrollHeight, + elementsCount: elements.length, + elements: elements + }; + }); + + console.log("Preview scrollHeight:", analysis.scrollHeight); + console.log("Found preview elements count:", analysis.elementsCount); + console.log("First 10 elements:", analysis.elements.slice(0, 10)); + + // Let's trigger PDF export and wait for the download + console.log("Opening Export dropdown..."); + await page.click('#exportDropdown'); + console.log("Clicking PDF export button..."); + const [download] = await Promise.all([ + page.waitForEvent('download', { timeout: 60000 }), + page.click('#export-pdf') + ]); + + const downloadPath = path.join(__dirname, 'exported_document.pdf'); + await download.saveAs(downloadPath); + console.log(`PDF saved successfully to ${downloadPath}`); +});