diff --git a/README.md b/README.md index c76629f..1f97664 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ npm run build ## Поддержка форматов -- QWord открывает `.docx` и сохраняет `.docx`; при импорте базовых DOCX-абзацев читает выравнивание, переносы строк, разрывы страниц, табуляцию, внешние и внутренние гиперссылки-якоря, простые таблицы, базовые растровые изображения, явные цвета текста, подсветку, размер и семейство шрифта из OOXML, а при экспорте DOCX сохраняются заголовки, абзацы, переносы строк, разрывы страниц, табуляция, выравнивание абзацев, списки, простые таблицы, внешние и внутренние гиперссылки-якоря, базовые растровые изображения и базовое inline-форматирование текста: полужирный, курсив, подчеркивание, зачеркивание, цвет текста, подсветка, размер и семейство шрифта. +- QWord открывает `.docx` и сохраняет `.docx`; при импорте базовых DOCX-абзацев читает выравнивание, переносы строк, разрывы страниц, табуляцию, закладки, внешние и внутренние гиперссылки-якоря, простые таблицы, базовые растровые изображения, явные цвета текста, подсветку, размер и семейство шрифта из OOXML, а при экспорте DOCX сохраняются заголовки, абзацы, переносы строк, разрывы страниц, табуляция, выравнивание абзацев, закладки из HTML `id`, списки, простые таблицы, внешние и внутренние гиперссылки-якоря, базовые растровые изображения и базовое inline-форматирование текста: полужирный, курсив, подчеркивание, зачеркивание, цвет текста, подсветка, размер и семейство шрифта. - QExcell открывает `.xlsx` и сохраняет `.xlsx`; сетка расширяется по фактически заполненным ячейкам, поддерживает несколько листов, скрытые и veryHidden листы через `sheet state`, закрепленные области через `sheetViews pane`, скрытие сетки через `sheetView showGridLines`, масштаб листа через `sheetView zoomScale`, автофильтр через `autoFilter ref`, цвет вкладки листа через `sheetPr tabColor`, ширину колонок через `cols/col width`, высоту строк через `row ht/customHeight`, активный лист через `workbookView activeTab`, формулы за пределами стартового диапазона A-H, shared formulas XLSX с относительными ссылками, базовые merged cells, внешние и внутренние гиперссылки ячеек, базовые диаграммы листа и базовое форматирование ячеек: полужирный, курсив, подчеркивание, зачеркивание, размер и семейство шрифта, прямые RGB/theme/indexed цвета текста с учетом `xl/theme/theme1.xml`, заливку, горизонтальное и вертикальное выравнивание, перенос текста, границы и цвет границ ячеек, числовые форматы XLSX для дат, процентов, денежных и дробных значений. - QPowerPoint открывает `.pptx` и сохраняет `.pptx`; импорт учитывает порядок слайдов из `presentation.xml`, переносы абзацев и ручные переносы строк `a:br` внутри текстовых блоков, скрытые слайды через атрибут `show`, а также заметки докладчика через relationships слайда, сохраняет скрытые слайды, заметки докладчика, базовые растровые изображения слайдов, пользовательский цвет фона слайда, а также цвет, размер и семейство шрифта текста заголовка/подзаголовка. - QOutlook открывает `.eml` и одиночные сообщения Outlook `.msg`, сохраняет письма в `.eml`; импорт EML поддерживает простые текстовые письма, `multipart/alternative`, `multipart/mixed`, `multipart/related`, `text/plain`, fallback из `text/html` в читаемый текст, base64, quoted-printable, MIME encoded-word заголовки UTF-8/windows-1251, MIME-вложения и inline-вложения с `Content-ID`, `To`, `Cc` и `Bcc`. Импорт MSG читает тему, отправителя, получателей `To/Cc/Bcc`, текст/HTML body и вложения, если эти поля доступны в MSG-файле. Новые письма можно адресовать через `To/Cc/Bcc` и сохранять в EML вместе с добавленными файлами. `.pst` и экспорт в `.msg` пока не реализованы. @@ -46,7 +46,7 @@ Desktop-меню поддерживает системные команды дл - `Ctrl/Cmd+Shift+E`: экспортировать текущую рабочую область в PDF. - `Ctrl/Cmd+P`: печать текущей рабочей области. -Импорт и экспорт реализуют базовую совместимость с Office Open XML, EML и чтением MSG: текст, базовое inline-форматирование, явные `w:br`, `w:br w:type="page"`, `w:tab`, `w:color`, `w:shd`, `w:highlight`, `w:sz`, `w:rFonts` и `w:strike` для переносов строк/разрывов страниц/табуляции/цвета/подсветки/размера/семейства/зачеркивания текста DOCX, выравнивание абзацев DOCX, внешние `w:hyperlink r:id` и внутренние `w:hyperlink w:anchor` гиперссылки DOCX, внешние и внутренние гиперссылки XLSX, базовые растровые изображения DOCX/PPTX, простые таблицы, несколько листов, скрытые/veryHidden листы через `sheet state`, закрепленные области через `sheetViews pane`, скрытие сетки через `sheetView showGridLines`, масштаб листа через `sheetView zoomScale`, автофильтр через `autoFilter ref`, цвет вкладки листа через `sheetPr tabColor`, ширину колонок через `cols/col width`, высоту строк через `row ht/customHeight` и активный лист XLSX через `workbookView activeTab`, ячейки, формулы, shared formulas XLSX с относительными ссылками, базовые merged cells, базовые стили, RGB/theme/indexed цвета XLSX с учетом `xl/theme/theme1.xml`, размеры и семейства шрифта, зачеркивание ячеек, горизонтальное и вертикальное выравнивание, перенос текста, границы и цвет границ ячеек, числовые форматы и простые bar/line/pie диаграммы ячеек XLSX, текстовые слайды PPTX с переносами абзацев и ручными переносами строк `a:br` при импорте, скрытые слайды PPTX через атрибут `show`, пользовательский фон слайдов PPTX, цвет, размер и семейство шрифта текста заголовка/подзаголовка PPTX, импорт и экспорт заметок докладчика, адресацию `To/Cc/Bcc`, MIME encoded-word заголовки UTF-8/windows-1251, MIME-вложения EML, inline-вложения EML с `Content-ID` и базовые поля одиночных MSG-писем. Сложные стили, макросы, сложные диаграммы, сложное позиционирование и обтекание изображений, PST, экспорт MSG и полная fidelity документов Microsoft Office в текущем инкременте не заявлены. +Импорт и экспорт реализуют базовую совместимость с Office Open XML, EML и чтением MSG: текст, базовое inline-форматирование, явные `w:br`, `w:br w:type="page"`, `w:tab`, `w:color`, `w:shd`, `w:highlight`, `w:sz`, `w:rFonts` и `w:strike` для переносов строк/разрывов страниц/табуляции/цвета/подсветки/размера/семейства/зачеркивания текста DOCX, выравнивание абзацев DOCX, закладки DOCX через `w:bookmarkStart`/`w:bookmarkEnd`, внешние `w:hyperlink r:id` и внутренние `w:hyperlink w:anchor` гиперссылки DOCX, внешние и внутренние гиперссылки XLSX, базовые растровые изображения DOCX/PPTX, простые таблицы, несколько листов, скрытые/veryHidden листы через `sheet state`, закрепленные области через `sheetViews pane`, скрытие сетки через `sheetView showGridLines`, масштаб листа через `sheetView zoomScale`, автофильтр через `autoFilter ref`, цвет вкладки листа через `sheetPr tabColor`, ширину колонок через `cols/col width`, высоту строк через `row ht/customHeight` и активный лист XLSX через `workbookView activeTab`, ячейки, формулы, shared formulas XLSX с относительными ссылками, базовые merged cells, базовые стили, RGB/theme/indexed цвета XLSX с учетом `xl/theme/theme1.xml`, размеры и семейства шрифта, зачеркивание ячеек, горизонтальное и вертикальное выравнивание, перенос текста, границы и цвет границ ячеек, числовые форматы и простые bar/line/pie диаграммы ячеек XLSX, текстовые слайды PPTX с переносами абзацев и ручными переносами строк `a:br` при импорте, скрытые слайды PPTX через атрибут `show`, пользовательский фон слайдов PPTX, цвет, размер и семейство шрифта текста заголовка/подзаголовка PPTX, импорт и экспорт заметок докладчика, адресацию `To/Cc/Bcc`, MIME encoded-word заголовки UTF-8/windows-1251, MIME-вложения EML, inline-вложения EML с `Content-ID` и базовые поля одиночных MSG-писем. Сложные стили, макросы, сложные диаграммы, сложное позиционирование и обтекание изображений, PST, экспорт MSG и полная fidelity документов Microsoft Office в текущем инкременте не заявлены. ## Сборка пакета diff --git a/src/io/wordOffice.test.ts b/src/io/wordOffice.test.ts index 7e90b31..c663620 100644 --- a/src/io/wordOffice.test.ts +++ b/src/io/wordOffice.test.ts @@ -29,6 +29,15 @@ describe("wordOffice helpers", () => { ]); }); + it("сохраняет HTML id абзацев и заголовков как закладки DOCX", () => { + const blocks = htmlToWordBlocks('

Раздел

Итог

'); + + expect(blocks).toEqual([ + { type: "heading", level: 1, text: "Раздел", runs: [{ text: "Раздел" }], bookmark: "Section1" }, + { type: "paragraph", text: "Итог", runs: [{ text: "Итог" }], bookmark: "Summary" } + ]); + }); + it("сохраняет базовое inline-форматирование текста для DOCX", () => { const blocks = htmlToWordBlocks(`

Обычный полужирный курсив подчеркнутый все стили

@@ -237,6 +246,16 @@ describe("wordOffice helpers", () => { expect(relationshipsXml).not.toContain('Target="#Section1"'); }); + it("записывает HTML id как DOCX bookmarkStart/bookmarkEnd", async () => { + const blob = await exportDocxBlob("Закладка.docx", '

Раздел

К разделу

'); + const zip = await JSZip.loadAsync(await blob.arrayBuffer()); + const documentXml = await zip.file("word/document.xml")?.async("string"); + + expect(documentXml).toMatch(/]*w:name="Section1"[^>]*\/>/); + expect(documentXml).toContain("]*w:anchor="Section1"[^>]*>/); + }); + it("записывает data URI изображения в DOCX media relationships", async () => { const blob = await exportDocxBlob("Изображение.docx", `

Логотип

Логотип`); const zip = await JSZip.loadAsync(await blob.arrayBuffer()); diff --git a/src/io/wordOffice.ts b/src/io/wordOffice.ts index b162797..c7147b8 100644 --- a/src/io/wordOffice.ts +++ b/src/io/wordOffice.ts @@ -34,8 +34,8 @@ export interface WordImageBlock { export type WordParagraphAlignment = "left" | "center" | "right" | "both"; export type WordBlock = - | { type: "heading"; level: 1 | 2; text: string; runs: WordInlineRun[]; alignment?: WordParagraphAlignment } - | { type: "paragraph"; text: string; runs: WordInlineRun[]; alignment?: WordParagraphAlignment } + | { type: "heading"; level: 1 | 2; text: string; runs: WordInlineRun[]; alignment?: WordParagraphAlignment; bookmark?: string } + | { type: "paragraph"; text: string; runs: WordInlineRun[]; alignment?: WordParagraphAlignment; bookmark?: string } | { type: "list"; ordered: boolean; items: WordListItem[] } | { type: "table"; rows: string[][] } | WordImageBlock; @@ -82,6 +82,7 @@ type DocxModule = { }; Paragraph: new (options: Record) => unknown; PageBreak?: new () => unknown; + Bookmark?: new (options: { id: string; children: unknown[] }) => unknown; Table: new (options: Record) => unknown; TableCell: new (options: Record) => unknown; TableRow: new (options: Record) => unknown; @@ -233,8 +234,16 @@ export function htmlToWordBlocks(html: string): WordBlock[] { const runs = elementInlineRuns(node); const text = inlineRunsText(runs); const alignment = paragraphAlignmentFromElement(node); + const bookmark = bookmarkFromElement(node); if (text) { - blocks.push({ type: "heading", level: tagName === "h1" ? 1 : 2, text, runs, ...(alignment ? { alignment } : {}) }); + blocks.push({ + type: "heading", + level: tagName === "h1" ? 1 : 2, + text, + runs, + ...(alignment ? { alignment } : {}), + ...(bookmark ? { bookmark } : {}) + }); } return; } @@ -277,22 +286,24 @@ export function htmlToWordBlocks(html: string): WordBlock[] { function wordBlockToDocxChildren(block: WordBlock, docx: DocxModule) { if (block.type === "heading") { + const children = wordInlineRunsToTextRuns(block.runs, docx); return [ new docx.Paragraph({ heading: block.level === 1 ? docx.HeadingLevel.HEADING_1 : docx.HeadingLevel.HEADING_2, spacing: { after: 160 }, ...docxAlignmentOption(block.alignment, docx), - children: wordInlineRunsToTextRuns(block.runs, docx) + children: bookmarkDocxChildren(block.bookmark, children, docx) }) ]; } if (block.type === "paragraph") { + const children = wordInlineRunsToTextRuns(block.runs, docx); return [ new docx.Paragraph({ spacing: { after: 160 }, ...docxAlignmentOption(block.alignment, docx), - children: wordInlineRunsToTextRuns(block.runs, docx) + children: bookmarkDocxChildren(block.bookmark, children, docx) }) ]; } @@ -416,6 +427,10 @@ function wordInlineRunsToTextRuns(runs: WordInlineRun[], docx: DocxModule) { }); } +function bookmarkDocxChildren(bookmark: string | undefined, children: unknown[], docx: DocxModule) { + return bookmark && docx.Bookmark ? [new docx.Bookmark({ id: bookmark, children })] : children; +} + function wordInlineRunToTextRunsForRun(run: WordInlineRun, docx: DocxModule, optionOverrides: Record = {}) { const option = { ...wordInlineRunToTextRunOption(run), ...optionOverrides }; const text = String(option.text ?? ""); @@ -531,15 +546,24 @@ function tableRows(table: Element) { function paragraphElementToBlocks(element: Element): WordBlock[] { const segments = collectInlineSegments(Array.from(element.childNodes), {}); const alignment = paragraphAlignmentFromElement(element); + const bookmark = bookmarkFromElement(element); const blocks: WordBlock[] = []; let pendingRuns: WordInlineRun[] = []; + let bookmarkApplied = false; const flushParagraph = () => { const runs = normalizeInlineRuns(pendingRuns); const text = inlineRunsText(runs); const hasPageBreak = runs.some((run) => run.text.includes(pageBreakMarker)); if (text || hasPageBreak) { - blocks.push({ type: "paragraph", text, runs, ...(alignment ? { alignment } : {}) }); + blocks.push({ + type: "paragraph", + text, + runs, + ...(alignment ? { alignment } : {}), + ...(bookmark && !bookmarkApplied ? { bookmark } : {}) + }); + bookmarkApplied = true; } pendingRuns = []; }; @@ -572,6 +596,10 @@ function paragraphAlignmentFromElement(element: Element): WordParagraphAlignment return normalized === "justify" ? "both" : undefined; } +function bookmarkFromElement(element: Element) { + return normalizedDocxAnchor(element.getAttribute("id") || element.getAttribute("name")); +} + function cssTextAlignValue(style: string) { return /(?:^|;)\s*text-align\s*:\s*([^;]+)/i.exec(style)?.[1] ?? ""; } @@ -674,6 +702,7 @@ async function styledDocxHtmlFromArrayBuffer(arrayBuffer: ArrayBuffer) { const result = orderedDocxDocumentToHtml(orderedParser.parse(documentXml), relationshipTargets.hyperlinks, imageDataByRelationshipId); return result.hasInlineColorOrHighlight || result.hasParagraphAlignment || + result.hasBookmark || result.hasTable || result.hasImage || result.hasLineBreak || @@ -699,6 +728,7 @@ function orderedDocxDocumentToHtml( const body = firstOrderedElement(orderedElementChildren(document), "w:body"); let hasInlineColorOrHighlight = false; let hasParagraphAlignment = false; + let hasBookmark = false; let hasTable = false; let hasImage = false; let hasLineBreak = false; @@ -711,6 +741,7 @@ function orderedDocxDocumentToHtml( const result = orderedDocxParagraphToHtml(block, hyperlinkTargets, imageDataByRelationshipId); hasInlineColorOrHighlight = hasInlineColorOrHighlight || result.hasInlineColorOrHighlight; hasParagraphAlignment = hasParagraphAlignment || result.hasParagraphAlignment; + hasBookmark = hasBookmark || result.hasBookmark; hasImage = hasImage || result.hasImage; hasLineBreak = hasLineBreak || result.hasLineBreak; hasPageBreak = hasPageBreak || result.hasPageBreak; @@ -734,6 +765,7 @@ function orderedDocxDocumentToHtml( html: blocks.join(""), hasInlineColorOrHighlight, hasParagraphAlignment, + hasBookmark, hasTable, hasImage, hasLineBreak, @@ -750,7 +782,9 @@ function orderedDocxParagraphToHtml( const properties = orderedElementToRecord(firstOrderedElement(orderedElementChildren(paragraph), "w:pPr")); const tagName = docxParagraphTagName(properties); const style = docxParagraphStyleAttribute(properties); + const bookmark = orderedDocxParagraphBookmark(paragraph); const hasParagraphAlignment = Boolean(style); + const hasBookmark = Boolean(bookmark); let hasInlineColorOrHighlight = false; let hasImage = false; let hasLineBreak = false; @@ -768,13 +802,16 @@ function orderedDocxParagraphToHtml( const content = runs.join(""); if (!content.trim()) { - return { html: "", hasInlineColorOrHighlight, hasParagraphAlignment, hasImage, hasLineBreak, hasPageBreak, hasTab }; + return { html: "", hasInlineColorOrHighlight, hasParagraphAlignment, hasBookmark, hasImage, hasLineBreak, hasPageBreak, hasTab }; } + const attributes = [style ? `style="${style}"` : "", bookmark ? `id="${escapeHtml(bookmark)}"` : ""].filter(Boolean).join(" "); + return { - html: `<${tagName}${style ? ` style="${style}"` : ""}>${content}`, + html: `<${tagName}${attributes ? ` ${attributes}` : ""}>${content}`, hasInlineColorOrHighlight, hasParagraphAlignment, + hasBookmark, hasImage, hasLineBreak, hasPageBreak, @@ -843,6 +880,11 @@ function docxParagraphStyleAttribute(properties: XmlRecord) { return ["left", "center", "right", "justify"].includes(htmlAlignment) ? `text-align: ${htmlAlignment};` : ""; } +function orderedDocxParagraphBookmark(paragraph: unknown) { + const bookmarkStart = firstOrderedElement(orderedElementChildren(paragraph), "w:bookmarkStart"); + return normalizedDocxAnchor(docxAttribute(orderedElementAttributes(bookmarkStart), "w:name") || docxAttribute(orderedElementAttributes(bookmarkStart), "name")); +} + function orderedDocxParagraphInlineRecords(paragraph: unknown, hyperlinkTargets: Record) { return orderedElementChildren(paragraph).flatMap((child) => { const name = orderedElementName(child); diff --git a/src/io/wordOfficeImport.test.ts b/src/io/wordOfficeImport.test.ts index 930bec7..7f0d771 100644 --- a/src/io/wordOfficeImport.test.ts +++ b/src/io/wordOfficeImport.test.ts @@ -184,6 +184,21 @@ describe("wordOffice DOCX import", () => { ]); }); + it("imports DOCX bookmarks as HTML ids", async () => { + const blob = await exportDocxBlob("Bookmark.docx", '

Section

Open section

'); + const file = new File([blob], "Bookmark.docx", { + type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + }); + const imported = await importDocxFile(file); + + expect(imported.html).toContain('

Section

'); + expect(imported.html).toContain('Open section'); + expect(htmlToWordBlocks(imported.html)).toEqual([ + { type: "heading", level: 1, text: "Section", runs: [{ text: "Section" }], bookmark: "Section1" }, + { type: "paragraph", text: "Open section", runs: [{ text: "Open section", href: "#Section1" }] } + ]); + }); + it("imports simple DOCX tables from OOXML", async () => { const blob = await exportDocxBlob( "Table.docx",