Skip to content
Merged
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
8 changes: 4 additions & 4 deletions src/usecases/apkg/ExportApkgToPdfUseCase.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,16 +91,16 @@ describe('ExportApkgToPdfUseCase', () => {
expect(htmlArg).toContain('Answer 3');
});

it('throws CardLimitExceededError for decks over 500 cards', async () => {
const parsed = makeParsed(501);
it('throws CardLimitExceededError for decks over 1000 cards', async () => {
const parsed = makeParsed(1001);
previewService.parse.mockResolvedValue(parsed);
previewService.getMeta.mockReturnValue(makeMeta(501));
previewService.getMeta.mockReturnValue(makeMeta(1001));

await expect(useCase.execute(Buffer.from('fake-apkg'))).rejects.toThrow(
CardLimitExceededError
);
await expect(useCase.execute(Buffer.from('fake-apkg'))).rejects.toThrow(
/501 cards/
/1001 cards/
);
expect(pdfRenderService.renderHtml).not.toHaveBeenCalled();
});
Expand Down
2 changes: 1 addition & 1 deletion src/usecases/apkg/ExportApkgToPdfUseCase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { RenderedCard } from '../../services/ApkgPreviewService/types';

const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);

const MAX_CARDS = 500;
const MAX_CARDS = 1000;

const SOUND_TAG_REGEX = /\[sound:([^\]]+)\]/g;
const AUDIO_EXTENSIONS = new Set([
Expand Down
1 change: 1 addition & 0 deletions web/src/pages/DocsPage/content-links.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const KNOWN_APP_PREFIXES = [
'/about',
'/changelog',
'/card-options',
'/print',
];

const LINK_RE = /(?<!!)\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
Expand Down
2 changes: 1 addition & 1 deletion web/src/pages/DocsPage/content/help/common-problems.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ If you saw an error and want the fix, find the heading below that matches what y

**Why it happened.** 2anki creates Anki decks; it doesn't read them. Uploading an existing `.apkg` isn't a flow we support.

**How to fix it.** Upload the source you used to make the deck (HTML export, Markdown, PDF, CSV) — not the deck itself. To open an `.apkg` you already have, see [Open your deck in Anki](/documentation/start-here/open-in-anki).
**How to fix it.** Upload the source you used to make the deck (HTML export, Markdown, PDF, CSV) — not the deck itself. To open an `.apkg` you already have, see [Open your deck in Anki](/documentation/start-here/open-in-anki). To print or share it as a PDF, use [Print Decks](/print).

## "PDF exceeds maximum page limit of 100 for free and anonymous users."

Expand Down
2 changes: 1 addition & 1 deletion web/src/pages/DocsPage/content/reference/print-export.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Uploaded `.apkg` files are removed within **2 hours** of the export. The PDF is
## Common mistakes

- **Wrong file type.** The print tool only accepts `.apkg`. If your source is a Notion export, a Markdown file, or a PDF, go to the [upload page](/documentation/start-here/upload-a-file) first to make the `.apkg`, then come back here.
- **Very large decks.** The export has an upper size — decks with thousands of cards may return "This deck is too large to print right now." Split the deck in Anki (use a filtered deck or export a subdeck) and run each through the print tool.
- **Very large decks.** Free print covers decks up to 1000 cards. Bigger decks return "PDF export supports up to 1000 cards." Upgrade for unlimited, or split the deck in Anki (use a filtered deck or export a subdeck) and run each through the print tool.
- **Free plan trying to export.** The export returns the upgrade message instead of a PDF. See [pricing](/pricing) for plans, or use a short [Day or Week Pass](/documentation/reference/plans) if you need it once.

## Related
Expand Down
84 changes: 84 additions & 0 deletions web/src/pages/PrintPage/components/PrintForm.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';

import PrintForm from './PrintForm';

const renderForm = () =>
render(
<MemoryRouter>
<PrintForm />
</MemoryRouter>
);

const pickApkg = (name = 'deck.apkg') => {
const file = new File(['fake'], name, { type: 'application/octet-stream' });
const input = screen.getByLabelText(/Drop an Anki deck/i, {
selector: 'input[type="file"]',
}) as HTMLInputElement;
fireEvent.change(input, { target: { files: [file] } });
};

describe('PrintForm', () => {
const originalFetch = globalThis.fetch;

beforeEach(() => {
globalThis.fetch = vi.fn();
});

afterEach(() => {
globalThis.fetch = originalFetch;
vi.restoreAllMocks();
});

test('shows the server card-limit message verbatim plus an Upgrade for unlimited link', async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: false,
status: 400,
clone() {
return {
json: async () => ({
message:
'This deck has 1873 cards. PDF export supports up to 1000 cards.',
}),
};
},
} as unknown as Response);

renderForm();
pickApkg();

await waitFor(() => {
expect(screen.getByText(/1873 cards/)).toBeInTheDocument();
});
expect(screen.getByText(/PDF export supports up to 1000 cards/)).toBeInTheDocument();

const upgrade = screen.getByRole('link', { name: /Upgrade for unlimited/i });
expect(upgrade).toHaveAttribute('href', '/pricing');
});

test('shows a friendly corrupted-file message when the server reports Invalid .apkg', async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: false,
status: 400,
clone() {
return {
json: async () => ({ message: 'Invalid .apkg file' }),
};
},
} as unknown as Response);

renderForm();
pickApkg();

await waitFor(() => {
expect(
screen.getByText(/Couldn't read this file/i)
).toBeInTheDocument();
});
expect(
screen.queryByRole('link', { name: /Upgrade for unlimited/i })
).not.toBeInTheDocument();
});
});
11 changes: 8 additions & 3 deletions web/src/pages/PrintPage/components/PrintForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ const WRONG_TYPE_MESSAGE =
'This tool works with Anki deck files (.apkg). To turn notes into an Anki deck, use the Upload page.';
const CORRUPTED_MESSAGE =
"Couldn't read this file. Make sure it's a valid Anki deck (.apkg) and try again.";
const TOO_LARGE_MESSAGE =
'This deck is too large to print right now. Try a deck with fewer cards.';
const GENERIC_ERROR_MESSAGE =
'Something went wrong while generating the PDF. Try again.';

const CARD_LIMIT_PATTERN = /PDF export supports up to/i;

function isApkgFile(name: string): boolean {
return /\.apkg$/i.test(name);
}
Expand All @@ -40,7 +40,6 @@ function toUserMessage(serverMessage: string, status: number): string {
if (status === 401) return AUTH_MESSAGE;
if (status === 402 || status === 403) return UPGRADE_MESSAGE;
if (/Invalid .apkg/i.test(serverMessage)) return CORRUPTED_MESSAGE;
if (/PDF export supports up to/i.test(serverMessage)) return TOO_LARGE_MESSAGE;
return serverMessage;
}

Expand Down Expand Up @@ -258,6 +257,12 @@ export default function PrintForm() {
<Link to="/login">Log in</Link>
</>
)}
{CARD_LIMIT_PATTERN.test(errorMessage) && (
<>
{' '}
<Link to="/pricing">Upgrade for unlimited</Link>
</>
)}
</p>
)}

Expand Down
13 changes: 12 additions & 1 deletion web/src/pages/UploadPage/components/UploadForm/UploadForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1013,11 +1013,22 @@ function UploadForm({ setErrorMessage }: Readonly<UploadFormProps>) {
const errorText = classified.detail
? `${classified.title} ${classified.detail}`
: classified.title;
const isExistingApkg =
localError != null && /already an Anki deck/i.test(localError.message);
return (
<div className={formStyles.stateContent}>
<WarningIcon className={formStyles.iconError} />
<p className={formStyles.errorTitle}>Something went wrong</p>
<p className={formStyles.errorBody}>{errorText}</p>
<p className={formStyles.errorBody}>
{errorText}
{isExistingApkg && (
<>
{' '}
Want to print or share it as a PDF?{' '}
<Link to="/print">Try Print Decks</Link>.
</>
)}
</p>
<button
type="button"
className={formStyles.actionButton}
Expand Down
2 changes: 2 additions & 0 deletions web/src/pages/WhatsNewPage/changelog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export interface ChangelogEntry {
}

export const changelog: ChangelogEntry[] = [
{ type: 'fix', title: 'Print page handles bigger decks — free covers up to 1000 cards, and the error tells you the exact count and cap if you go over', date: '2026-05-20' },
{ type: 'fix', title: 'Dropped an .apkg on the upload page by mistake? The error now points you to Print Decks instead of a dead end', date: '2026-05-20' },
{ type: 'feature', title: 'Print 1 PDF a month for free — drop your .apkg on the Print page and we make the PDF, no subscription needed for the first one', date: '2026-05-19' },
{ type: 'style', title: 'Upload page — quiet upsell appears under the form for free users explaining the 100-card monthly limit', date: '2026-05-19' },
{ type: 'fix', title: 'Sidebar — your free-plan card count stays on one line instead of wrapping over three rows', date: '2026-05-19' },
Expand Down