mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-21 17:44:11 +00:00
✨ feat: Improve DOCX rendering and surface filename in panel header
Two UX improvements based on hands-on use of the office preview pipeline. DOCX rendering — mammoth strips the navy banners, cell shading, and column layouts that direct-formatted docs apply (python-docx-style output is a common case). The flat `<p><strong>X</strong></p>` and bare `<table><tr><td>` it emits looks washed out next to the source. Three targeted compensations: - Style map promotes `Title`, `Subtitle`, `Heading 1` thru `Heading 6`, and `Quote` paragraphs to their semantic HTML equivalents (mammoth's default only handles Heading 1-6, missing Title/Subtitle/Quote). - Extra CSS scoped to `.lc-docx` gives the first table row sticky- looking header styling regardless of `<thead>` (mammoth never emits `<thead>`), adds zebra striping, and treats the python-docx `<p><strong>X</strong></p>` section-heading idiom as a pseudo-h2 with a thin accent left border so document structure survives the round trip. Headings get a left accent or underline so they read as headings instead of just bold paragraphs. - Sanitizer's `allowedAttributes` opens `class` on the heading and block tags the styleMap and CSS heuristics rely on. `<script>`, event handlers, javascript: URLs, etc. are still stripped — the existing security regression suite catches any drift. Panel header — `Artifacts.tsx` showed a generic "Preview" pill for preview-only artifacts. Single-tab Radio is a no-op; surfacing the document filename there gives the user something useful in the chrome without taking real estate. `displayFilename` handles the sandbox dotfile suffix the upload pipeline applies. Tests: html.spec.ts +1 (new CSS-emission lock), 71/71. Backend files suite 428/428. Client 308/308.
This commit is contained in:
parent
b21989905e
commit
f18a95486e
3 changed files with 111 additions and 15 deletions
|
|
@ -12,6 +12,7 @@ import DownloadArtifact from './DownloadArtifact';
|
|||
import ArtifactVersion from './ArtifactVersion';
|
||||
import ArtifactTabs from './ArtifactTabs';
|
||||
import { isPreviewOnlyArtifact } from '~/utils/artifacts';
|
||||
import { displayFilename } from '~/components/Chat/Messages/Content/Parts/attachmentTypes';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
|
@ -98,10 +99,21 @@ export default function Artifacts() {
|
|||
* snap the active tab when the user lands on an office artifact while
|
||||
* the code tab is selected. */
|
||||
const isPreviewOnly = isPreviewOnlyArtifact(currentArtifact?.type);
|
||||
const tabOptions = useMemo(
|
||||
() => (isPreviewOnly ? allTabOptions.filter((opt) => opt.value !== 'code') : allTabOptions),
|
||||
[allTabOptions, isPreviewOnly],
|
||||
);
|
||||
const tabOptions = useMemo(() => {
|
||||
if (!isPreviewOnly) {
|
||||
return allTabOptions;
|
||||
}
|
||||
/* When only the preview tab is shown, the generic "Preview" label is
|
||||
* a no-op pill — surface the document filename there instead. The
|
||||
* Play icon stays as a visual cue for "rendered preview". `displayFilename`
|
||||
* handles the sandbox dotfile suffix the upload pipeline applies. */
|
||||
const filename = displayFilename(currentArtifact?.title);
|
||||
const previewTab = allTabOptions.find((opt) => opt.value === 'preview');
|
||||
if (!previewTab) {
|
||||
return allTabOptions;
|
||||
}
|
||||
return [filename ? { ...previewTab, label: filename } : previewTab];
|
||||
}, [allTabOptions, isPreviewOnly, currentArtifact?.title]);
|
||||
useEffect(() => {
|
||||
if (isPreviewOnly && activeTab === 'code') {
|
||||
setActiveTab('preview');
|
||||
|
|
|
|||
|
|
@ -36,6 +36,22 @@ describe('Office HTML producers', () => {
|
|||
expect(html).not.toMatch(/<script\b/i);
|
||||
expect(html).not.toMatch(/onerror=/i);
|
||||
});
|
||||
|
||||
test('emits the docx-specific extra CSS in the wrapper', async () => {
|
||||
/* The flat output mammoth produces for direct-formatted documents
|
||||
* (e.g. python-docx with no named styles) leans on the extra CSS
|
||||
* to give tables sticky-looking first-row headers and to style
|
||||
* `<p><strong>X</strong></p>` patterns as section headings.
|
||||
* Asserting those rules survive into the document means the
|
||||
* improvement isn't accidentally removable. */
|
||||
const html = await wordDocToHtml(readFixture('sample.docx'));
|
||||
// First-row table-header heuristic.
|
||||
expect(html).toContain('.lc-docx table tr:first-child td');
|
||||
// Section-heading-style heuristic for bold-only-child paragraphs.
|
||||
expect(html).toContain('.lc-docx p:has(> strong:only-child)');
|
||||
// Heading visual styling (left accent).
|
||||
expect(html).toContain('.lc-docx h2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('excelSheetToHtml', () => {
|
||||
|
|
|
|||
|
|
@ -93,12 +93,21 @@ export async function sanitizeOfficeHtml(html: string): Promise<string> {
|
|||
div: ['class'],
|
||||
span: ['class'],
|
||||
section: ['class', 'aria-labelledby'],
|
||||
h1: ['id'],
|
||||
h2: ['id'],
|
||||
h3: ['id'],
|
||||
h4: ['id'],
|
||||
h5: ['id'],
|
||||
h6: ['id'],
|
||||
h1: ['id', 'class'],
|
||||
h2: ['id', 'class'],
|
||||
h3: ['id', 'class'],
|
||||
h4: ['id', 'class'],
|
||||
h5: ['id', 'class'],
|
||||
h6: ['id', 'class'],
|
||||
p: ['class'],
|
||||
ol: ['class'],
|
||||
ul: ['class'],
|
||||
li: ['class'],
|
||||
blockquote: ['class'],
|
||||
tbody: ['class'],
|
||||
thead: ['class'],
|
||||
tr: ['class'],
|
||||
tfoot: ['class'],
|
||||
},
|
||||
/* `data:` is intentionally NOT in the global scheme list — it's only
|
||||
* allowed for `<img src>` (mammoth inlines DOCX images as base64
|
||||
|
|
@ -234,11 +243,70 @@ ${bodyHtml}
|
|||
* DOCX → HTML
|
||||
* ============================================================================= */
|
||||
|
||||
/**
|
||||
* Style-map directives that broaden mammoth's default heading detection.
|
||||
* Mammoth's stock map only promotes paragraphs whose ms-word style name
|
||||
* matches `Heading 1`/`Heading 2`/etc. — useless for code-generated
|
||||
* docs (e.g. python-docx output) that apply direct character formatting
|
||||
* instead of named styles. The `Title`/`Subtitle` mappings catch the
|
||||
* built-in title styles used by Word's Insert > Cover Page workflow;
|
||||
* explicit `Heading 1` thru `Heading 6` mappings retain mammoth's
|
||||
* defaults; and `:fresh` tells mammoth not to merge consecutive
|
||||
* matching paragraphs into a single heading element.
|
||||
*/
|
||||
const DOCX_STYLE_MAP = [
|
||||
"p[style-name='Title'] => h1.lc-docx-title:fresh",
|
||||
"p[style-name='Subtitle'] => h2.lc-docx-subtitle:fresh",
|
||||
"p[style-name='Heading 1'] => h1:fresh",
|
||||
"p[style-name='Heading 2'] => h2:fresh",
|
||||
"p[style-name='Heading 3'] => h3:fresh",
|
||||
"p[style-name='Heading 4'] => h4:fresh",
|
||||
"p[style-name='Heading 5'] => h5:fresh",
|
||||
"p[style-name='Heading 6'] => h6:fresh",
|
||||
"p[style-name='Quote'] => blockquote:fresh",
|
||||
];
|
||||
|
||||
/**
|
||||
* CSS layered on top of `wrapAsDocument`'s base styles to give mammoth's
|
||||
* flat output more visual structure. Mammoth strips the navy banners,
|
||||
* cell shading, and column layouts that direct-formatted docs apply, so
|
||||
* the source loses most of its presentation. We compensate with three
|
||||
* targeted heuristics:
|
||||
*
|
||||
* 1. The first row of any `<table>` gets sticky-header styling
|
||||
* regardless of `<thead>` (mammoth never emits `<thead>`).
|
||||
* 2. Tables get alternating row stripes so dense data blocks stay
|
||||
* scannable even without the source's hand-tuned shading.
|
||||
* 3. A bold-only-child paragraph (`<p><strong>X</strong></p>`) is the
|
||||
* python-docx idiom for a "section heading"; styled as a
|
||||
* pseudo-h2 with a thin accent border so the document's structure
|
||||
* survives the round-trip.
|
||||
*/
|
||||
const DOCX_EXTRA_CSS = `
|
||||
.lc-docx h1 { font-size: 1.5rem; font-weight: 700; margin: 1em 0 0.5em; padding-bottom: 0.3em; border-bottom: 2px solid var(--border); }
|
||||
.lc-docx h2 { font-size: 1.2rem; font-weight: 600; margin: 1em 0 0.4em; padding-left: 0.6em; border-left: 3px solid var(--link); }
|
||||
.lc-docx h3 { font-size: 1.05rem; font-weight: 600; margin: 0.8em 0 0.3em; color: var(--link); }
|
||||
.lc-docx-title { text-align: center; border-bottom: none; }
|
||||
.lc-docx-subtitle { text-align: center; border-left: none; padding-left: 0; color: var(--muted); font-style: italic; font-weight: 400; }
|
||||
.lc-docx p { margin: 0.5em 0; }
|
||||
.lc-docx p:has(> strong:only-child) { margin: 1em 0 0.4em; padding-left: 0.6em; border-left: 3px solid var(--link); font-size: 1.05rem; }
|
||||
.lc-docx p:has(> strong:only-child) strong { font-weight: 600; }
|
||||
.lc-docx table { width: 100%; max-width: 100%; }
|
||||
.lc-docx td, .lc-docx th { white-space: normal; }
|
||||
.lc-docx table tr:first-child td { background: var(--header-bg); font-weight: 600; }
|
||||
.lc-docx table tr:nth-child(even):not(:first-child) td { background: var(--row-alt); }
|
||||
.lc-docx ul, .lc-docx ol { margin: 0.5em 0; padding-left: 1.6em; }
|
||||
.lc-docx li { margin: 0.15em 0; }
|
||||
.lc-docx blockquote { border-left: 3px solid var(--border); color: var(--muted); margin: 0.8em 0; padding: 0.2em 0 0.2em 0.8em; }
|
||||
`.trim();
|
||||
|
||||
/**
|
||||
* Convert a `.docx` buffer to a sandboxed HTML document. Uses mammoth's
|
||||
* default style map (paragraph styles, runs, tables, hyperlinks, lists) and
|
||||
* inlines images as base64 `data:` URIs. The output is sanitized and wrapped
|
||||
* in our document template before return.
|
||||
* default conversion (paragraph styles, runs, tables, hyperlinks, lists,
|
||||
* inline images as base64 `data:` URIs) augmented by `DOCX_STYLE_MAP`
|
||||
* for richer heading detection and `DOCX_EXTRA_CSS` for table-first-row
|
||||
* + section-heading heuristics that compensate for mammoth's habit of
|
||||
* stripping direct-formatted presentation.
|
||||
*
|
||||
* Pre-flights the buffer through `assertSafeZipSize` so a zip-bomb DOCX
|
||||
* is rejected BEFORE it reaches mammoth — mammoth's internal extraction
|
||||
|
|
@ -249,9 +317,9 @@ ${bodyHtml}
|
|||
export async function wordDocToHtml(buffer: Buffer): Promise<string> {
|
||||
await assertSafeZipSize(buffer, { name: 'docx' });
|
||||
const { convertToHtml } = await import('mammoth');
|
||||
const result = await convertToHtml({ buffer });
|
||||
const result = await convertToHtml({ buffer }, { styleMap: DOCX_STYLE_MAP });
|
||||
const sanitized = await sanitizeOfficeHtml(result.value);
|
||||
return wrapAsDocument(`<article class="lc-docx">${sanitized}</article>`);
|
||||
return wrapAsDocument(`<article class="lc-docx">${sanitized}</article>`, DOCX_EXTRA_CSS);
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue