diff --git a/README.md b/README.md index 1b78f02..8951ff9 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ npm run build - QWord открывает `.docx` и сохраняет `.docx`; при импорте базовых DOCX-абзацев читает выравнивание, явные цвета текста и подсветку из OOXML, а при экспорте DOCX сохраняются заголовки, абзацы, выравнивание абзацев, списки, простые таблицы, внешние гиперссылки, базовые растровые изображения и базовое inline-форматирование текста: полужирный, курсив, подчеркивание, цвет текста и подсветка. - QExcell открывает `.xlsx` и сохраняет `.xlsx`; сетка расширяется по фактически заполненным ячейкам, поддерживает несколько листов, формулы за пределами стартового диапазона A-H, базовые merged cells, внешние гиперссылки ячеек, базовые диаграммы листа и базовое форматирование ячеек: полужирный, курсив, подчеркивание, цвет текста, заливку, горизонтальное выравнивание и числовые форматы XLSX для дат, процентов, денежных и дробных значений. -- QPowerPoint открывает `.pptx` и сохраняет `.pptx`; импорт учитывает порядок слайдов из `presentation.xml`, читает заметки докладчика через relationships слайда и сохраняет базовые растровые изображения слайдов. +- QPowerPoint открывает `.pptx` и сохраняет `.pptx`; импорт учитывает порядок слайдов из `presentation.xml`, читает заметки докладчика через relationships слайда, сохраняет базовые растровые изображения слайдов и пользовательский цвет фона слайда. - QOutlook открывает `.eml` и одиночные сообщения Outlook `.msg`, сохраняет письма в `.eml`; импорт EML поддерживает простые текстовые письма, `multipart/alternative`, `multipart/mixed`, `text/plain`, fallback из `text/html` в читаемый текст, base64, quoted-printable и MIME-вложения. Импорт MSG читает тему, отправителя, получателей, текст/HTML body и вложения, если эти поля доступны в MSG-файле. Новые письма можно сохранять в EML вместе с добавленными файлами. `.pst` и экспорт в `.msg` пока не реализованы. В desktop-режиме Electron используются системные диалоги открытия и сохранения файлов. В web-режиме остаются браузерный выбор файла и скачивание результата. @@ -46,7 +46,7 @@ Desktop-меню поддерживает системные команды дл - `Ctrl/Cmd+Shift+E`: экспортировать текущую рабочую область в PDF. - `Ctrl/Cmd+P`: печать текущей рабочей области. -Импорт и экспорт реализуют базовую совместимость с Office Open XML, EML и чтением MSG: текст, базовое inline-форматирование, явные `w:color`, `w:shd` и `w:highlight` для цвета/подсветки текста DOCX, выравнивание абзацев DOCX, внешние гиперссылки DOCX/XLSX, базовые растровые изображения DOCX/PPTX, простые таблицы, несколько листов XLSX, ячейки, формулы, базовые merged cells, базовые стили, цвета текста ячеек, горизонтальное выравнивание ячеек, числовые форматы и простые bar/line/pie диаграммы ячеек XLSX, текстовые слайды PPTX, заметки докладчика, MIME-вложения EML и базовые поля одиночных MSG-писем. Сложные стили, макросы, сложные диаграммы, сложное позиционирование и обтекание изображений, PST, экспорт MSG и полная fidelity документов Microsoft Office в текущем инкременте не заявлены. +Импорт и экспорт реализуют базовую совместимость с Office Open XML, EML и чтением MSG: текст, базовое inline-форматирование, явные `w:color`, `w:shd` и `w:highlight` для цвета/подсветки текста DOCX, выравнивание абзацев DOCX, внешние гиперссылки DOCX/XLSX, базовые растровые изображения DOCX/PPTX, простые таблицы, несколько листов XLSX, ячейки, формулы, базовые merged cells, базовые стили, цвета текста ячеек, горизонтальное выравнивание ячеек, числовые форматы и простые bar/line/pie диаграммы ячеек XLSX, текстовые слайды PPTX, пользовательский фон слайдов PPTX, заметки докладчика, MIME-вложения EML и базовые поля одиночных MSG-писем. Сложные стили, макросы, сложные диаграммы, сложное позиционирование и обтекание изображений, PST, экспорт MSG и полная fidelity документов Microsoft Office в текущем инкременте не заявлены. ## Сборка пакета diff --git a/src/App.tsx b/src/App.tsx index 3854879..3281f24 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,7 @@ const STORAGE_KEY = "qoffice-state-v1"; function migrateQOfficeState(state: QOfficeState): QOfficeState { const launchChecklistAttachments = initialState.mail.messages.find((message) => message.id === "mail-1")?.attachments; const sampleSheet = initialState.workbook.sheets.find((sheet) => sheet.id === "sheet-1"); + const sampleSlidesById = Object.fromEntries(initialState.deck.slides.map((slide) => [slide.id, slide])); const sampleWordHtml = initialState.word.html; const needsSampleWordColor = state.word.id === initialState.word.id && @@ -64,11 +65,25 @@ function migrateQOfficeState(state: QOfficeState): QOfficeState { }) : state.workbook.sheets; + const slides = + state.deck.id === initialState.deck.id + ? state.deck.slides.map((slide) => { + const sampleSlide = sampleSlidesById[slide.id]; + if (!sampleSlide?.backgroundColor || slide.backgroundColor) { + return slide; + } + + changed = true; + return { ...slide, backgroundColor: sampleSlide.backgroundColor }; + }) + : state.deck.slides; + return changed ? { ...state, word: needsSampleWordColor ? { ...state.word, html: sampleWordHtml } : state.word, workbook: { ...state.workbook, sheets }, + deck: { ...state.deck, slides }, mail: { ...state.mail, messages } } : state; diff --git a/src/data/sampleData.ts b/src/data/sampleData.ts index 2adbe20..9a6887d 100644 --- a/src/data/sampleData.ts +++ b/src/data/sampleData.ts @@ -111,7 +111,8 @@ export const initialState: QOfficeState = { title: "Дорожная карта QOffice", subtitle: "Единая рабочая среда для документов, данных, презентаций и коммуникации.", notes: "Начать с цели продукта и текущего этапа.", - theme: "classic" + theme: "classic", + backgroundColor: "EAF6FF" }, { id: "slide-2", diff --git a/src/features/qpowerpoint/QPowerPoint.tsx b/src/features/qpowerpoint/QPowerPoint.tsx index 7503eb3..4a6c3ee 100644 --- a/src/features/qpowerpoint/QPowerPoint.tsx +++ b/src/features/qpowerpoint/QPowerPoint.tsx @@ -19,6 +19,16 @@ const themeLabels: Record = { graphite: "Графит" }; +const backgroundColorSwatches = [ + { color: "", label: "Фон темы" }, + { color: "FFFFFF", label: "Белый фон" }, + { color: "EAF6FF", label: "Светло-синий фон" }, + { color: "D9EAD3", label: "Светло-зеленый фон" }, + { color: "FFF2CC", label: "Светло-желтый фон" }, + { color: "FCE4D6", label: "Коралловый фон" }, + { color: "1F2937", label: "Темный фон" } +]; + const slideWidthInches = 13.333; const slideHeightInches = 7.5; @@ -69,6 +79,16 @@ function slideImageStyle(image: SlideImage) { }; } +function slideCanvasStyle(slide: Slide) { + const backgroundColor = normalizedHexColor(slide.backgroundColor); + return backgroundColor ? { background: `#${backgroundColor}` } : undefined; +} + +function normalizedHexColor(value?: string) { + const raw = value?.trim().replace(/^#/, "").toUpperCase() ?? ""; + return /^[0-9A-F]{6}$/.test(raw) ? raw : ""; +} + function clonedSlideImages(images?: SlideImage[]) { const suffix = Date.now(); return images?.map((image, index) => ({ ...image, id: `${image.id}-copy-${suffix}-${index}` })); @@ -324,7 +344,7 @@ export function QPowerPoint({ deck, onChange }: QPowerPointProps) { -
+
{activeImages.map((image) => (

Изображения

diff --git a/src/io/powerPointOffice.test.ts b/src/io/powerPointOffice.test.ts index 1a4e77c..e9865d0 100644 --- a/src/io/powerPointOffice.test.ts +++ b/src/io/powerPointOffice.test.ts @@ -201,6 +201,45 @@ describe("powerPointOffice helpers", () => { expect(mediaFiles.length).toBeGreaterThan(0); }); + it("импортирует и экспортирует пользовательский цвет фона слайда", async () => { + const zip = new JSZip(); + zip.file( + "ppt/presentation.xml", + `` + ); + zip.file( + "ppt/_rels/presentation.xml.rels", + `` + ); + zip.file("ppt/slides/slide1.xml", slideXmlWithBackground("Фон слайда", "FCE4D6")); + + const importedBlob = await zip.generateAsync({ + type: "blob", + mimeType: "application/vnd.openxmlformats-officedocument.presentationml.presentation" + }); + const imported = await importPptxFile(new File([importedBlob], "Фон.pptx", { type: importedBlob.type })); + + expect(imported.slides[0].backgroundColor).toBe("FCE4D6"); + + const exportedBlob = await exportPptxBlob({ + title: "Фон.pptx", + slides: [ + { + id: "slide-1", + title: "Фон слайда", + subtitle: "", + notes: "", + theme: "classic", + backgroundColor: "FCE4D6" + } + ] + }); + const exportedZip = await JSZip.loadAsync(await exportedBlob.arrayBuffer()); + const exportedSlideXml = await exportedZip.file("ppt/slides/slide1.xml")?.async("string"); + + expect(exportedSlideXml).toContain("FCE4D6"); + }); + it("нормализует пробелы в импортированном тексте", () => { expect(normalizeWhitespace(" Строка\tс \n пробелами ")).toBe("Строка с пробелами"); }); @@ -212,6 +251,10 @@ function slideXml(...texts: string[]) { .join("")}`; } +function slideXmlWithBackground(title: string, color: string) { + return `${title}`; +} + function slideXmlWithPicture(title: string) { return `${title}`; } diff --git a/src/io/powerPointOffice.ts b/src/io/powerPointOffice.ts index 386c979..5735ff5 100644 --- a/src/io/powerPointOffice.ts +++ b/src/io/powerPointOffice.ts @@ -54,6 +54,7 @@ export async function importPptxFile(file: File): Promise { const data = normalizedImageData(image.src); if (!data || !slide.addImage) { @@ -335,6 +337,19 @@ function normalizedImageData(src: string) { return /^data:image\/(?:png|jpe?g|gif|bmp|svg\+xml);base64,/i.test(value) ? value : ""; } +function slideBackgroundColor(slideXml: unknown) { + const background = firstRecordByKey(slideXml, "bg"); + const backgroundProperties = childRecord(background, "bgPr"); + const solidFill = childRecord(backgroundProperties, "solidFill"); + const srgbColor = childRecord(solidFill, "srgbClr"); + return normalizeHexColor(stringValue(srgbColor.val ?? srgbColor["@_val"])); +} + +function normalizeHexColor(value?: string) { + const raw = value?.trim().replace(/^#/, "").toUpperCase() ?? ""; + return /^[0-9A-F]{6}$/.test(raw) ? raw : ""; +} + function numberAttribute(record: Record, name: string) { const value = Number.parseFloat(stringValue(record[name] ?? record[`@_${name}`])); return Number.isFinite(value) ? value : undefined; diff --git a/src/styles.css b/src/styles.css index e8885a3..f6c95d8 100644 --- a/src/styles.css +++ b/src/styles.css @@ -422,6 +422,12 @@ button { padding-left: 2px; } +.slide-background-swatches { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + .fill-swatch { position: relative; width: 24px; diff --git a/src/types.ts b/src/types.ts index ac94d60..69d8a46 100644 --- a/src/types.ts +++ b/src/types.ts @@ -64,6 +64,7 @@ export interface Slide { subtitle: string; notes: string; theme: "classic" | "ocean" | "graphite"; + backgroundColor?: string; images?: SlideImage[]; }