Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/base/docx-file-def.gts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import FileTypeDocIcon from '@cardstack/boxel-icons/file-type-doc';
import { contains, field } from './card-api';
import { FileDef, type ByteStream, type SerializedFile } from './file-api';
import { OfficeMetadataField } from './file-formats/metadata-fields';
import { OFFICE_FAMILY_SCREENSHOTS } from './file-formats/office-captures';
import { OfficePreview } from './file-formats/office-preview';
import { extractDocxMetadata } from './docx-meta-extractor';
import { extractOfficeMetadata } from './office-extract';
Expand Down Expand Up @@ -39,6 +40,11 @@ export class DocxDef extends FileDef {

static previewComponent: FilePreviewComponent = OfficePreview;

// The fitted poster: a capture-only render of the extracted structure's
// first unit, keyed on the file's bytes and flagged useAsThumbnail. The
// typed placeholder stays the fallback until a capture serves.
static screenshots = OFFICE_FAMILY_SCREENSHOTS;

static async extractAttributes(
url: string,
getStream: () => Promise<ByteStream>,
Expand Down
234 changes: 234 additions & 0 deletions packages/base/file-formats/office-captures.gts
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
// The Office families' declared-screenshot capture: a capture-only component
// that renders the first unit of the extracted structure — a document's
// opening text flow, a deck's title slide, a workbook's first sheet — as a
// page-shaped poster for the fitted cell and the thumbnail fallback chain.
//
// Rasterization goes through the screenshot engine by design: the browser
// has no decode path for OOXML, so the extracted-structure rendering IS the
// office viewer, and screenshotting it is the pattern rather than an
// exception. Everything here is synchronous DOM over already-extracted
// fields, so no readiness signal is needed — the engine's settle covers it.
import GlimmerComponent from '@glimmer/component';
import { cached } from '@glimmer/tracking';

import { eq } from '@cardstack/boxel-ui/helpers';

import { ensureFileViewModel, type FileViewModel } from './file-view-model';

import type { ScreenshotSpec } from '../card-api';

// How much of the opening text flow a document poster shows. The capture box
// crops overflow anyway; the cap just keeps the capture render from laying
// out an entire manuscript to show one page.
const POSTER_BLOCK_BUDGET = 12;
const POSTER_ROW_BUDGET = 12;
const POSTER_CELL_BUDGET = 4;

interface CaptureSignature {
Args: {
model: any;
};
Element: HTMLElement;
}

export class OfficePosterCapture extends GlimmerComponent<CaptureSignature> {
@cached
get model(): FileViewModel {
return ensureFileViewModel(this.args.model, 'embedded');
}

get meta() {
return this.model.officeMetadata;
}

get kind(): string {
return this.meta?.kind || this.model.previewKind || 'word';
}

@cached
get parsedPreview(): any {
let raw = this.meta?.previewJson;
if (!raw) {
return undefined;
}
try {
return JSON.parse(raw);
} catch {
return undefined;
}
}

get blocks(): { style: string; text: string }[] {
if (this.kind !== 'word') {
return [];
}
return (this.parsedPreview?.blocks ?? []).slice(0, POSTER_BLOCK_BUDGET);
}

get titleSlide(): { title?: string; bullets: string[] } | undefined {
if (this.kind !== 'presentation') {
return undefined;
}
return this.parsedPreview?.slides?.[0];
}

get sheet(): { name: string; rows: string[][] } | undefined {
if (this.kind !== 'spreadsheet') {
return undefined;
}
return this.parsedPreview?.sheets?.[0];
}

get sheetRows(): string[][] {
return (this.sheet?.rows ?? [])
.slice(0, POSTER_ROW_BUDGET)
.map((row: string[]) => row.slice(0, POSTER_CELL_BUDGET));
}

get heading(): string {
return (
this.meta?.title || this.model.baseName || this.model.name || 'Document'
);
}

<template>
<div class='office-poster' data-kind={{this.kind}}>
{{#if (eq this.kind 'presentation')}}
<div class='slide'>
<div class='slide-title'>{{if
this.titleSlide.title
this.titleSlide.title
this.heading
}}</div>
{{#each this.titleSlide.bullets as |bullet|}}
<div class='slide-bullet'>{{bullet}}</div>
{{/each}}
</div>
{{else if (eq this.kind 'spreadsheet')}}
<div class='sheet'>
{{#if this.sheet.name}}<div
class='sheet-tab'
>{{this.sheet.name}}</div>{{/if}}
<table class='sheet-grid'>
<tbody>
{{#each this.sheetRows as |row|}}
<tr>
{{#each row as |cell|}}
<td>{{cell}}</td>
{{/each}}
</tr>
{{/each}}
</tbody>
</table>
</div>
{{else}}
<div class='page'>
<div class='page-title'>{{this.heading}}</div>
{{#each this.blocks as |block|}}
<p class='page-block' data-style={{block.style}}>{{block.text}}</p>
{{/each}}
</div>
{{/if}}
</div>
<style scoped>
/* One white page filling the capture box; overflow past the box is the
crop, exactly as a physical first page would crop. */
.office-poster {
position: absolute;
inset: 0;
overflow: hidden;
background: #fff;
color: #1a1a1a;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.page {
padding: 14px 12px;
}
.page-title {
font-size: 0.8125rem;
font-weight: 700;
line-height: 1.25;
margin-bottom: 8px;
}
.page-block {
font-size: 0.5625rem;
line-height: 1.45;
margin: 0 0 5px;
}
.page-block[data-style='heading'] {
font-size: 0.6875rem;
font-weight: 600;
margin-top: 7px;
}
.slide {
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
gap: 6px;
padding: 14px 12px;
background: #fff;
}
.slide-title {
font-size: 0.875rem;
font-weight: 700;
line-height: 1.2;
}
.slide-bullet {
font-size: 0.5625rem;
line-height: 1.4;
padding-left: 10px;
position: relative;
}
.slide-bullet::before {
content: '•';
position: absolute;
left: 0;
}
.sheet {
padding: 8px;
}
.sheet-tab {
display: inline-block;
font-size: 0.5rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #555;
border: 1px solid #d8d8d8;
border-bottom: 0;
border-radius: 3px 3px 0 0;
padding: 2px 6px;
}
.sheet-grid {
width: 100%;
border-collapse: collapse;
}
.sheet-grid td {
border: 1px solid #e2e2e2;
font-size: 0.5rem;
line-height: 1.3;
padding: 2px 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 48px;
}
</style>
</template>
}

// One roster shared by DocxDef / PptxDef / XlsxDef: a `poster` at the
// recommended thumbnail box (the CardsGrid tile, 170×250 at the default
// deviceScaleFactor of 2), keyed on file content, feeding the thumbnail
// fallback chain and the fitted cell through the view model's thumbnail
// seam. The typed placeholder remains the fallback until a capture serves.
export const OFFICE_FAMILY_SCREENSHOTS: Record<string, ScreenshotSpec> = {
poster: {
render: OfficePosterCapture,
width: 170,
height: 250,
keyBy: 'file-content',
useAsThumbnail: true,
},
};
17 changes: 8 additions & 9 deletions packages/base/file-formats/office-preview.gts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@
// information derived from the source" provenance; it branches on the file's
// `previewKind` for the domain-specific body.
//
// The fitted first-page/first-slide poster is deliberately not drawn here — it
// needs the derived-artifact contract (CS-12231) to rasterize and store a real
// rendition. Once that lands and populates `thumbnailUrl`, the preview stage
// prefers the real poster over the typed placeholder automatically, with no
// change to this component.
// The fitted first-page/first-slide poster is deliberately not drawn here:
// the families' declared `poster` capture (see `office-captures`) renders the
// extracted structure's first unit during the prerender pass, and the preview
// stage prefers that rendition over the typed placeholder through the view
// model's `thumbnailUrl` — the placeholder is the graceful fallback for an
// uncaptured file.
import GlimmerComponent from '@glimmer/component';
import { cached } from '@glimmer/tracking';

Expand Down Expand Up @@ -296,10 +297,8 @@ export class OfficePreview extends GlimmerComponent<FilePreviewSignature> {
content: '';
position: absolute;
inset: 12% 12%;
background-image: repeating-linear-gradient(
var(--border) 0 1px,
transparent 1px 16px
),
background-image:
repeating-linear-gradient(var(--border) 0 1px, transparent 1px 16px),
repeating-linear-gradient(
90deg,
var(--border) 0 1px,
Expand Down
6 changes: 6 additions & 0 deletions packages/base/pptx-file-def.gts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import FileTypePptIcon from '@cardstack/boxel-icons/file-type-ppt';
import { contains, field } from './card-api';
import { FileDef, type ByteStream, type SerializedFile } from './file-api';
import { OfficeMetadataField } from './file-formats/metadata-fields';
import { OFFICE_FAMILY_SCREENSHOTS } from './file-formats/office-captures';
import { OfficePreview } from './file-formats/office-preview';
import { extractPptxMetadata } from './pptx-meta-extractor';
import { extractOfficeMetadata } from './office-extract';
Expand Down Expand Up @@ -30,6 +31,11 @@ export class PptxDef extends FileDef {

static previewComponent: FilePreviewComponent = OfficePreview;

// The fitted poster: a capture-only render of the extracted structure's
// first unit, keyed on the file's bytes and flagged useAsThumbnail. The
// typed placeholder stays the fallback until a capture serves.
static screenshots = OFFICE_FAMILY_SCREENSHOTS;

static async extractAttributes(
url: string,
getStream: () => Promise<ByteStream>,
Expand Down
6 changes: 6 additions & 0 deletions packages/base/xlsx-file-def.gts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import FileTypeXlsIcon from '@cardstack/boxel-icons/file-type-xls';
import { contains, field } from './card-api';
import { FileDef, type ByteStream, type SerializedFile } from './file-api';
import { OfficeMetadataField } from './file-formats/metadata-fields';
import { OFFICE_FAMILY_SCREENSHOTS } from './file-formats/office-captures';
import { OfficePreview } from './file-formats/office-preview';
import { extractXlsxMetadata } from './xlsx-meta-extractor';
import { extractOfficeMetadata } from './office-extract';
Expand Down Expand Up @@ -31,6 +32,11 @@ export class XlsxDef extends FileDef {

static previewComponent: FilePreviewComponent = OfficePreview;

// The fitted poster: a capture-only render of the extracted structure's
// first unit, keyed on the file's bytes and flagged useAsThumbnail. The
// typed placeholder stays the fallback until a capture serves.
static screenshots = OFFICE_FAMILY_SCREENSHOTS;

static async extractAttributes(
url: string,
getStream: () => Promise<ByteStream>,
Expand Down
41 changes: 41 additions & 0 deletions packages/realm-server/tests/declared-screenshots-file-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,47 @@ module(basename(import.meta.filename), function (hooks) {
);
});

test('the Office family captures a first-page poster onto the file row', async function (assert) {
let docxBytes = new Uint8Array(
readFileSync(
fileURLToPath(
new URL(
'../../experiments-realm/filedef-fixtures/samples/docx-simple.docx',
import.meta.url,
),
),
),
);
let baseline = await maxPrerenderHtmlJobId(testDbAdapter, realm.url);
await realm.write('memo.docx', docxBytes);
await settlePrerenderHtmlJobs(testDbAdapter, realm.url, {
afterJobId: baseline,
timeout: 60000,
});

let fileRow = await prerenderedHtmlRowFor(
testDbAdapter,
`${testRealm}memo.docx`,
'file',
);
assert.ok(fileRow, 'the file row exists');
let manifest = fileRow!.screenshots as ScreenshotManifest | null;
assert.ok(manifest?.poster, 'the poster landed on the file row');
assert.true(
manifest!.poster.useAsThumbnail,
'the poster feeds the thumbnail chain',
);
assert.ok(
startsWith(objectBytes(manifest!.poster.objectKey), PNG_MAGIC),
'the capture is a PNG',
);
let fitted = JSON.stringify(fileRow!.fitted_html ?? {});
assert.ok(
fitted.includes(`_screenshot/memo.docx?name=poster`),
`the fitted rendering carries the poster URL (got: ${fitted.slice(0, 500)})`,
);
});

test('an unchanged file carries its capture forward; a content change recaptures', async function (assert) {
await writeAndSettle('sample.mismatch', 'carry me');
let firstRow = await prerenderedHtmlRowFor(
Expand Down
Loading