From 2f1c70852df7893ca019b1c683c9748df49642ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D1=83=D1=80=D0=BD=D0=B0=D1=82=20=D0=90=D0=BD=D0=B4?= =?UTF-8?q?=D1=80=D0=B5=D0=B9?= Date: Tue, 2 Jun 2026 05:57:52 +0300 Subject: [PATCH] Import QWord DOCX lists from numbering --- README.md | 4 +- src/io/wordOffice.ts | 188 ++++++++++++++++++++++++++------ src/io/wordOfficeImport.test.ts | 33 ++++++ 3 files changed, 189 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index cba0ac2..0e0734c 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ npm run build ## Поддержка форматов -- QWord открывает `.docx` и сохраняет `.docx`; при импорте базовых DOCX-абзацев читает выравнивание, отступы `w:ind`, переносы строк, разрывы страниц, табуляцию, закладки, внешние и внутренние гиперссылки-якоря, простые таблицы, базовые растровые изображения, явные цвета текста, подсветку, размер и семейство шрифта из OOXML, а при экспорте DOCX сохраняются заголовки, абзацы, переносы строк, разрывы страниц, табуляция, выравнивание и отступы абзацев из HTML `margin-left`/`text-indent`, закладки из HTML `id`, списки, простые таблицы, внешние и внутренние гиперссылки-якоря, базовые растровые изображения и базовое inline-форматирование текста: полужирный, курсив, подчеркивание, зачеркивание, цвет текста, подсветка, размер и семейство шрифта. +- QWord открывает `.docx` и сохраняет `.docx`; при импорте базовых DOCX-абзацев читает выравнивание, отступы `w:ind`, переносы строк, разрывы страниц, табуляцию, закладки, внешние и внутренние гиперссылки-якоря, маркированные и нумерованные списки через `w:numPr`/`numbering.xml`, простые таблицы, базовые растровые изображения, явные цвета текста, подсветку, размер и семейство шрифта из OOXML, а при экспорте DOCX сохраняются заголовки, абзацы, переносы строк, разрывы страниц, табуляция, выравнивание и отступы абзацев из HTML `margin-left`/`text-indent`, закладки из HTML `id`, списки, простые таблицы, внешние и внутренние гиперссылки-якоря, базовые растровые изображения и базовое inline-форматирование текста: полужирный, курсив, подчеркивание, зачеркивание, цвет текста, подсветка, размер и семейство шрифта. - QExcell открывает `.xlsx` и сохраняет `.xlsx`; сетка расширяется по фактически заполненным ячейкам, поддерживает несколько листов, скрытые и veryHidden листы через `sheet state`, закрепленные области через `sheetViews pane`, скрытие сетки через `sheetView showGridLines`, масштаб листа через `sheetView zoomScale`, автофильтр через `autoFilter ref`, область печати через `_xlnm.Print_Area`, цвет вкладки листа через `sheetPr tabColor`, ширину и скрытие колонок через `cols/col width` и `col hidden`, высоту и скрытие строк через `row ht/customHeight` и `row hidden`, управление скрытием выбранной строки и столбца в интерфейсе, активный лист через `workbookView activeTab`, формулы за пределами стартового диапазона A-H, shared formulas XLSX с относительными ссылками, базовые merged cells, внешние и внутренние гиперссылки ячеек, legacy-заметки ячеек через `comments.xml` и VML `legacyDrawing`, базовые диаграммы листа и базовое форматирование ячеек: полужирный, курсив, подчеркивание, зачеркивание, размер и семейство шрифта, прямые RGB/theme/indexed цвета текста с учетом `xl/theme/theme1.xml`, заливку, горизонтальное и вертикальное выравнивание, перенос текста, границы и цвет границ ячеек, числовые форматы XLSX для дат, процентов, денежных и дробных значений. - QPowerPoint открывает `.pptx` и сохраняет `.pptx`; импорт учитывает порядок слайдов из `presentation.xml`, переносы абзацев и ручные переносы строк `a:br` внутри текстовых блоков, маркированные и нумерованные списки подзаголовка через `a:buChar`/`a:buAutoNum`, скрытые слайды через атрибут `show`, переходы `p:transition` для `fade`/`push`/`wipe`, а также заметки докладчика через 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`, `Reply-To`, `Message-ID`, `In-Reply-To`, `References`, `Importance`, `X-Priority` и `Priority`. Импорт MSG читает тему, отправителя, получателей `To/Cc/Bcc`, `Reply-To`, `Message-ID`, `In-Reply-To`, `References`, `Importance`, `X-Priority` и `Priority` из транспортных заголовков, текст/HTML body и вложения, если эти поля доступны в MSG-файле. Новые письма можно адресовать через `To/Cc/Bcc`, задавать `Reply-To` и сохранять в EML вместе с добавленными файлами. `.pst` и экспорт в `.msg` пока не реализованы. @@ -59,7 +59,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:jc`/`w:ind`, закладки DOCX через `w:bookmarkStart`/`w:bookmarkEnd`, внешние `w:hyperlink r:id` и внутренние `w:hyperlink w:anchor` гиперссылки DOCX, внешние и внутренние гиперссылки XLSX, legacy-заметки ячеек XLSX через `comments.xml`, relationship `comments`, VML `vmlDrawing` и `legacyDrawing`, базовые растровые изображения DOCX/PPTX, простые таблицы, несколько листов, скрытые/veryHidden листы через `sheet state`, закрепленные области через `sheetViews pane`, скрытие сетки через `sheetView showGridLines`, масштаб листа через `sheetView zoomScale`, автофильтр через `autoFilter ref`, область печати XLSX через `_xlnm.Print_Area`, цвет вкладки листа через `sheetPr tabColor`, ширину и скрытие колонок через `cols/col width`/`col hidden`, высоту и скрытие строк через `row ht/customHeight`/`row hidden` и активный лист 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 через `p:transition` для `fade`/`push`/`wipe`, пользовательский фон слайдов PPTX, цвет, размер и семейство шрифта текста заголовка/подзаголовка PPTX, импорт и экспорт заметок докладчика, адресацию `To/Cc/Bcc/Reply-To`, заголовки цепочки писем `Message-ID`/`In-Reply-To`/`References`, заголовки приоритета `Importance`/`X-Priority`/`Priority`, 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 через `w:jc`/`w:ind`, закладки DOCX через `w:bookmarkStart`/`w:bookmarkEnd`, внешние `w:hyperlink r:id` и внутренние `w:hyperlink w:anchor` гиперссылки DOCX, маркированные и нумерованные списки DOCX через `w:numPr` и `numbering.xml`, внешние и внутренние гиперссылки XLSX, legacy-заметки ячеек XLSX через `comments.xml`, relationship `comments`, VML `vmlDrawing` и `legacyDrawing`, базовые растровые изображения DOCX/PPTX, простые таблицы, несколько листов, скрытые/veryHidden листы через `sheet state`, закрепленные области через `sheetViews pane`, скрытие сетки через `sheetView showGridLines`, масштаб листа через `sheetView zoomScale`, автофильтр через `autoFilter ref`, область печати XLSX через `_xlnm.Print_Area`, цвет вкладки листа через `sheetPr tabColor`, ширину и скрытие колонок через `cols/col width`/`col hidden`, высоту и скрытие строк через `row ht/customHeight`/`row hidden` и активный лист 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 через `p:transition` для `fade`/`push`/`wipe`, пользовательский фон слайдов PPTX, цвет, размер и семейство шрифта текста заголовка/подзаголовка PPTX, импорт и экспорт заметок докладчика, адресацию `To/Cc/Bcc/Reply-To`, заголовки цепочки писем `Message-ID`/`In-Reply-To`/`References`, заголовки приоритета `Importance`/`X-Priority`/`Priority`, 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.ts b/src/io/wordOffice.ts index bf4a96b..07a1069 100644 --- a/src/io/wordOffice.ts +++ b/src/io/wordOffice.ts @@ -104,6 +104,8 @@ type InlineFormat = Omit; type InlineSegment = { type: "runs"; runs: WordInlineRun[] } | { type: "image"; image: WordImageBlock }; type DocxInlineRecord = { run: unknown; text: string; href?: string }; type DocxImageDataByRelationshipId = Record; +type DocxNumberingDefinitions = Record; +type DocxListInfo = { ordered: boolean; level: number; key: string }; const mammothStyleMap = [ "p[style-name='Title'] => h1:fresh", @@ -772,8 +774,10 @@ async function styledDocxHtmlFromArrayBuffer(arrayBuffer: ArrayBuffer) { const orderedParser = new XMLParser({ ...xmlParserOptions, preserveOrder: true }); const relationshipsXml = await zip.file("word/_rels/document.xml.rels")?.async("string"); const relationshipTargets = relationshipsXml ? docxRelationshipTargetsById(parser.parse(relationshipsXml)) : emptyDocxRelationshipTargets; + const numberingXml = await zip.file("word/numbering.xml")?.async("string"); + const numberingDefinitions = numberingXml ? docxNumberingDefinitions(parser.parse(numberingXml)) : {}; const imageDataByRelationshipId = await docxImageDataByRelationshipId(zip, relationshipTargets.images); - const result = orderedDocxDocumentToHtml(orderedParser.parse(documentXml), relationshipTargets.hyperlinks, imageDataByRelationshipId); + const result = orderedDocxDocumentToHtml(orderedParser.parse(documentXml), relationshipTargets.hyperlinks, imageDataByRelationshipId, numberingDefinitions); return result.hasInlineColorOrHighlight || result.hasParagraphAlignment || result.hasBookmark || @@ -781,7 +785,8 @@ async function styledDocxHtmlFromArrayBuffer(arrayBuffer: ArrayBuffer) { result.hasImage || result.hasLineBreak || result.hasPageBreak || - result.hasTab + result.hasTab || + result.hasList ? result.html : ""; } catch { @@ -790,13 +795,14 @@ async function styledDocxHtmlFromArrayBuffer(arrayBuffer: ArrayBuffer) { } function isSimpleStyledDocxDocument(documentXml: string) { - return !/ = {}, - imageDataByRelationshipId: DocxImageDataByRelationshipId = {} + imageDataByRelationshipId: DocxImageDataByRelationshipId = {}, + numberingDefinitions: DocxNumberingDefinitions = {} ) { const document = firstOrderedElement(toArray(documentXml), "w:document"); const body = firstOrderedElement(orderedElementChildren(document), "w:body"); @@ -808,32 +814,69 @@ function orderedDocxDocumentToHtml( let hasLineBreak = false; let hasPageBreak = false; let hasTab = false; - const blocks = orderedElementChildren(body) - .flatMap((block) => { - const name = orderedElementName(block); - if (name === "w:p") { - 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; - hasTab = hasTab || result.hasTab; - return result.html ? [result.html] : []; - } - if (name === "w:tbl") { - const result = orderedDocxTableToHtml(block, hyperlinkTargets, imageDataByRelationshipId); - hasTable = hasTable || Boolean(result.html); - hasImage = hasImage || result.hasImage; - hasLineBreak = hasLineBreak || result.hasLineBreak; - hasPageBreak = hasPageBreak || result.hasPageBreak; - hasTab = hasTab || result.hasTab; - return result.html ? [result.html] : []; + let hasList = false; + const blocks: string[] = []; + let openList: DocxListInfo | null = null; + let listItems: string[] = []; + + const flushList = () => { + if (!openList || listItems.length === 0) { + openList = null; + listItems = []; + return; + } + + const tagName = openList.ordered ? "ol" : "ul"; + blocks.push(`<${tagName}>${listItems.join("")}`); + openList = null; + listItems = []; + }; + + orderedElementChildren(body).forEach((block) => { + const name = orderedElementName(block); + if (name === "w:p") { + const result = orderedDocxParagraphToHtml(block, hyperlinkTargets, imageDataByRelationshipId, numberingDefinitions); + hasInlineColorOrHighlight = hasInlineColorOrHighlight || result.hasInlineColorOrHighlight; + hasParagraphAlignment = hasParagraphAlignment || result.hasParagraphAlignment; + hasBookmark = hasBookmark || result.hasBookmark; + hasImage = hasImage || result.hasImage; + hasLineBreak = hasLineBreak || result.hasLineBreak; + hasPageBreak = hasPageBreak || result.hasPageBreak; + hasTab = hasTab || result.hasTab; + hasList = hasList || Boolean(result.list); + + if (!result.html) { + return; } - return []; - }); + if (result.list) { + if (!openList || openList.key !== result.list.key) { + flushList(); + openList = result.list; + } + listItems.push(result.html); + return; + } + + flushList(); + blocks.push(result.html); + return; + } + + if (name === "w:tbl") { + flushList(); + const result = orderedDocxTableToHtml(block, hyperlinkTargets, imageDataByRelationshipId, numberingDefinitions); + hasTable = hasTable || Boolean(result.html); + hasImage = hasImage || result.hasImage; + hasLineBreak = hasLineBreak || result.hasLineBreak; + hasPageBreak = hasPageBreak || result.hasPageBreak; + hasTab = hasTab || result.hasTab; + if (result.html) { + blocks.push(result.html); + } + } + }); + flushList(); return { html: blocks.join(""), @@ -844,19 +887,22 @@ function orderedDocxDocumentToHtml( hasImage, hasLineBreak, hasPageBreak, - hasTab + hasTab, + hasList }; } function orderedDocxParagraphToHtml( paragraph: unknown, hyperlinkTargets: Record, - imageDataByRelationshipId: DocxImageDataByRelationshipId = {} + imageDataByRelationshipId: DocxImageDataByRelationshipId = {}, + numberingDefinitions: DocxNumberingDefinitions = {} ) { const properties = orderedElementToRecord(firstOrderedElement(orderedElementChildren(paragraph), "w:pPr")); const tagName = docxParagraphTagName(properties); const style = docxParagraphStyleAttribute(properties); const bookmark = orderedDocxParagraphBookmark(paragraph); + const list = docxParagraphListInfo(properties, numberingDefinitions); const hasParagraphAlignment = Boolean(style); const hasBookmark = Boolean(bookmark); let hasInlineColorOrHighlight = false; @@ -876,10 +922,23 @@ function orderedDocxParagraphToHtml( const content = runs.join(""); if (!content.trim()) { - return { html: "", hasInlineColorOrHighlight, hasParagraphAlignment, hasBookmark, hasImage, hasLineBreak, hasPageBreak, hasTab }; + return { html: "", hasInlineColorOrHighlight, hasParagraphAlignment, hasBookmark, hasImage, hasLineBreak, hasPageBreak, hasTab, list }; } const attributes = [style ? `style="${style}"` : "", bookmark ? `id="${escapeHtml(bookmark)}"` : ""].filter(Boolean).join(" "); + if (list) { + return { + html: `${content}`, + hasInlineColorOrHighlight, + hasParagraphAlignment, + hasBookmark, + hasImage, + hasLineBreak, + hasPageBreak, + hasTab, + list + }; + } return { html: `<${tagName}${attributes ? ` ${attributes}` : ""}>${content}`, @@ -889,14 +948,16 @@ function orderedDocxParagraphToHtml( hasImage, hasLineBreak, hasPageBreak, - hasTab + hasTab, + list }; } function orderedDocxTableToHtml( table: unknown, hyperlinkTargets: Record, - imageDataByRelationshipId: DocxImageDataByRelationshipId = {} + imageDataByRelationshipId: DocxImageDataByRelationshipId = {}, + numberingDefinitions: DocxNumberingDefinitions = {} ) { let hasImage = false; let hasLineBreak = false; @@ -908,7 +969,7 @@ function orderedDocxTableToHtml( .map((cell) => { const paragraphs = orderedDirectElements(cell, "w:p") .map((paragraph) => { - const result = orderedDocxParagraphToHtml(paragraph, hyperlinkTargets, imageDataByRelationshipId); + const result = orderedDocxParagraphToHtml(paragraph, hyperlinkTargets, imageDataByRelationshipId, numberingDefinitions); hasImage = hasImage || result.hasImage; hasLineBreak = hasLineBreak || result.hasLineBreak; hasPageBreak = hasPageBreak || result.hasPageBreak; @@ -970,6 +1031,65 @@ function docxParagraphIndentStyles(properties: XmlRecord) { ].filter(Boolean); } +function docxParagraphListInfo(properties: XmlRecord, numberingDefinitions: DocxNumberingDefinitions): DocxListInfo | undefined { + const numbering = childRecord(properties, "w:numPr"); + const numId = docxAttribute(childRecord(numbering, "w:numId"), "w:val"); + if (!numId) { + return undefined; + } + + const level = normalizedNumberingLevel(docxAttribute(childRecord(numbering, "w:ilvl"), "w:val")); + const definition = numberingDefinitions[`${numId}:${level}`] ?? numberingDefinitions[`${numId}:0`]; + const ordered = definition?.ordered ?? false; + return { + ordered, + level, + key: `${ordered ? "ol" : "ul"}:${numId}:${level}` + }; +} + +function docxNumberingDefinitions(numberingXml: unknown): DocxNumberingDefinitions { + const numbering = childRecord(numberingXml, "w:numbering"); + const abstractFormats: Record> = {}; + + toArray(numbering["w:abstractNum"]).forEach((abstractNumbering) => { + const abstractRecord = asRecord(abstractNumbering); + const abstractId = docxAttribute(abstractRecord, "w:abstractNumId"); + if (!abstractId) { + return; + } + + const levels = toArray(abstractRecord["w:lvl"]).reduce>((formats, levelRecord) => { + const level = asRecord(levelRecord); + const levelIndex = normalizedNumberingLevel(docxAttribute(level, "w:ilvl")); + const format = docxAttribute(childRecord(level, "w:numFmt"), "w:val").toLowerCase(); + formats[levelIndex] = { ordered: format !== "bullet" }; + return formats; + }, {}); + + abstractFormats[abstractId] = levels; + }); + + return toArray(numbering["w:num"]).reduce((definitions, numRecord) => { + const numberingRecord = asRecord(numRecord); + const numId = docxAttribute(numberingRecord, "w:numId"); + const abstractId = docxAttribute(childRecord(numberingRecord, "w:abstractNumId"), "w:val"); + if (!numId || !abstractId) { + return definitions; + } + + Object.entries(abstractFormats[abstractId] ?? {}).forEach(([level, definition]) => { + definitions[`${numId}:${level}`] = definition; + }); + return definitions; + }, {}); +} + +function normalizedNumberingLevel(value: unknown) { + const level = Number.parseInt(String(value ?? ""), 10); + return Number.isFinite(level) && level >= 0 ? Math.min(level, 8) : 0; +} + function orderedDocxParagraphBookmark(paragraph: unknown) { const bookmarkStart = firstOrderedElement(orderedElementChildren(paragraph), "w:bookmarkStart"); return normalizedDocxAnchor(docxAttribute(orderedElementAttributes(bookmarkStart), "w:name") || docxAttribute(orderedElementAttributes(bookmarkStart), "name")); diff --git a/src/io/wordOfficeImport.test.ts b/src/io/wordOfficeImport.test.ts index eb3648c..67c3d6f 100644 --- a/src/io/wordOfficeImport.test.ts +++ b/src/io/wordOfficeImport.test.ts @@ -225,6 +225,39 @@ describe("wordOffice DOCX import", () => { ]); }); + it("imports DOCX bullet and numbered lists from OOXML numbering", async () => { + const blob = await exportDocxBlob( + "Lists.docx", + '
  • Bullet one
  • Bullet two
  1. Step one
  2. Step two
' + ); + const file = new File([blob], "Lists.docx", { + type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + }); + const imported = await importDocxFile(file); + + expect(imported.html).toContain("
    "); + expect(imported.html).toContain("
      "); + expect(imported.html).toContain("color: #0F5FAE;"); + expect(htmlToWordBlocks(imported.html)).toEqual([ + { + type: "list", + ordered: false, + items: [ + { text: "Bullet one", runs: [{ text: "Bullet " }, { text: "one", bold: true }] }, + { text: "Bullet two", runs: [{ text: "Bullet two", color: "0F5FAE" }] } + ] + }, + { + type: "list", + ordered: true, + items: [ + { text: "Step one", runs: [{ text: "Step one" }] }, + { text: "Step two", runs: [{ text: "Step two" }] } + ] + } + ]); + }); + it("imports styled DOCX content when the document also contains images", async () => { const blob = await exportDocxBlob( "Mixed.docx",