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
48 changes: 48 additions & 0 deletions apps/api/src/__tests__/pdf-parser-options.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import {
getPDFBlocks,
getPDFMode,
getPDFPageMarkdown,
getPDFPageMarkers,
scrapeOptions,
shouldParseImages,
shouldParsePDF,
} from "../controllers/v2/types";

function parsePdfParser(parser: Record<string, unknown>) {
Expand Down Expand Up @@ -63,3 +66,48 @@ describe("deprecated pageMarkdown alias", () => {
).toBe(true);
});
});

describe("image parser", () => {
it("is on by default, like pdf", () => {
expect(scrapeOptions.parse({}).parsers).toEqual(["pdf", "image"]);
expect(shouldParseImages(undefined)).toBe(true);
expect(shouldParseImages(["pdf", "image"])).toBe(true);
expect(shouldParsePDF(["pdf", "image"])).toBe(true);
});

it("opts out with an explicit list that omits it", () => {
expect(shouldParseImages([])).toBe(false);
expect(shouldParseImages(["pdf"])).toBe(false);
expect(shouldParseImages([{ type: "pdf", mode: "ocr" }])).toBe(false);
expect(scrapeOptions.parse({ parsers: ["pdf"] }).parsers).toEqual(["pdf"]);
});

it("accepts the string and object forms", () => {
expect(shouldParseImages(["image"])).toBe(true);
expect(shouldParseImages([{ type: "image" }])).toBe(true);
expect(shouldParseImages([{ type: "pdf", mode: "ocr" }, "image"])).toBe(
true,
);
expect(
scrapeOptions.parse({ parsers: ["pdf", { type: "image" }] }).parsers,
).toEqual(["pdf", { type: "image" }]);
});

it("leaves the pdf parser alone", () => {
expect(shouldParsePDF(["image"])).toBe(false);
expect(getPDFMode(["pdf", "image"])).toBe("auto");
expect(getPDFMode([{ type: "image" }, { type: "pdf", mode: "ocr" }])).toBe(
"ocr",
);
});

it("rejects the plural and unknown options", () => {
expect(scrapeOptions.safeParse({ parsers: ["images"] }).success).toBe(
false,
);
expect(
scrapeOptions.safeParse({ parsers: [{ type: "image", mode: "ocr" }] })
.success,
).toBe(false);
});
});
59 changes: 53 additions & 6 deletions apps/api/src/__tests__/snips/v2/scrape-image-ocr.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,8 @@ describeIf(SHOULD_RUN)("Image OCR (f-e and fire-pdf dependent)", () => {
it(
"OCRs a PNG URL as a one-page document",
async () => {
// Image OCR is on by default for a flagged team: no parsers option
// needed.
const response = await scrape(
{
url: `${TEST_SUITE_WEBSITE}/firecrawl-wordmark.png`,
Expand All @@ -495,29 +497,71 @@ describeIf(SHOULD_RUN)("Image OCR (f-e and fire-pdf dependent)", () => {
expect(response.metadata.statusCode).toBe(200);
// An image is not a PDF: no page count is reported for it.
expect(response.metadata.numPages).toBeUndefined();
expect(response.warning).toBeUndefined();
},
scrapeTimeout,
);

it(
"returns the raw image when parsing is disabled",
"OCRs images with the image parser alone",
async () => {
// The object form is reserved for future options, and the pdf parser
// is not needed for an image.
const response = await scrape(
{
url: `${TEST_SUITE_WEBSITE}/firecrawl-wordmark.png`,
formats: ["markdown"],
parsers: [],
parsers: [{ type: "image" }],
},
identity,
);

// Base64 of the PNG signature.
expect(response.markdown).toMatch(/^iVBORw0KGgo/);
expect(response.markdown).toMatch(/fire/i);
expect(response.metadata.contentType).toBe("image/png");
},
scrapeTimeout,
);

it(
"does not OCR images when the parsers list omits the image parser",
async () => {
// An explicit list without "image" opts out: the request keeps the
// unsupported-file rejection, and the error names the parser. The
// tests above OCR'd this URL already, so this also checks that the
// index does not hand that cached output to a request that opted
// out.
const response = await scrapeWithFailure(
{
url: `${TEST_SUITE_WEBSITE}/firecrawl-wordmark.png`,
formats: ["markdown"],
parsers: ["pdf"],
},
identity,
);

expect(response.error).toContain("cannot process");
expect(response.error).toContain('"image"');
},
scrapeTimeout,
);

it(
"serves the raw image through the rawBase64 format",
async () => {
const response = await scrape(
{
url: `${TEST_SUITE_WEBSITE}/firecrawl-wordmark.png`,
formats: ["rawBase64"],
},
identity,
);

// Base64 of the PNG signature.
expect(response.rawBase64).toMatch(/^iVBORw0KGgo/);
},
scrapeTimeout,
);

it(
"OCRs images submitted through batch scrape",
async () => {
Expand All @@ -544,11 +588,13 @@ describeIf(SHOULD_RUN)("Image OCR (f-e and fire-pdf dependent)", () => {
async () => {
// A long-lived public sample photo: exercises the image/jpeg browser
// handoff. The scrape itself only succeeds with non-empty markdown, so
// the assertions stick to the stable handoff properties.
// the assertions stick to the stable handoff properties. The bare
// string form of the image parser is exercised here.
const response = await scrape(
{
url: "https://www.gstatic.com/webp/gallery/1.jpg",
formats: ["markdown"],
parsers: ["image"],
},
identity,
);
Expand Down Expand Up @@ -655,12 +701,13 @@ describe("Image OCR for a team without the imageOcr flag", () => {
"scrape (f-e dependent)",
() => {
it(
"keeps failing image URLs as unsupported files",
"keeps failing image URLs as unsupported files even with the image parser",
async () => {
const response = await scrapeWithFailure(
{
url: `${TEST_SUITE_WEBSITE}/firecrawl-wordmark.png`,
formats: ["markdown"],
parsers: ["pdf", "image"],
},
ungatedIdentity,
);
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/__tests__/snips/v2/scrape.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1505,6 +1505,8 @@ describe("Scrape tests", () => {
expect(response.markdown).toContain("PDF Test File");
expect(response.metadata.title).toContain("PDF Test Page");
expect(response.metadata.numPages).toBe(1);
// A complete parse must not carry the partial-scrape warning.
expect(response.warning).toBeUndefined();
},
scrapeTimeout,
);
Expand Down
43 changes: 39 additions & 4 deletions apps/api/src/controllers/v2/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -522,9 +522,28 @@ const pdfParserWithOptions = z
return normalized;
});

/**
* Raster image OCR (PNG, JPEG, TIFF, GIF, BMP). Like `pdf` it is part of the
* default list, so a request that says nothing about parsers OCRs image URLs
* (behind the imageOcr team flag while it rolls out); an explicit list that
* omits it (`["pdf"]`, `[]`) opts out and keeps the historical
* unsupported-file rejection. The object form carries no options yet; it
* exists so options can be added later without a breaking change.
*/
const imageParserWithOptions = z.strictObject({
type: z.literal("image"),
});

const parsersSchema = z
.array(z.union([z.literal("pdf"), pdfParserWithOptions]))
.prefault(["pdf"]);
.array(
z.union([
z.literal("pdf"),
pdfParserWithOptions,
z.literal("image"),
imageParserWithOptions,
]),
)
.prefault(["pdf", "image"]);

type Parsers = z.infer<typeof parsersSchema>;

Expand All @@ -539,6 +558,22 @@ export function shouldParsePDF(parsers?: Parsers): boolean {
});
}

/**
* Whether the request wants raster image OCR. Like PDFs, images are parsed
* by default: `undefined` means the default list, while an explicit list
* that omits `image` (including `[]`) opts out.
*/
export function shouldParseImages(parsers?: Parsers): boolean {
if (!parsers) return true;
return parsers.some(
parser =>
parser === "image" ||
(typeof parser === "object" &&
parser !== null &&
parser.type === "image"),
);
}

export function getPDFMaxPages(parsers?: Parsers): number | undefined {
if (!parsers) return undefined;
const pdfParser = parsers.find(parser => {
Expand Down Expand Up @@ -1997,7 +2032,7 @@ export function fromV0ScrapeOptions(
parsers:
pageOptions.parsePDF !== undefined
? pageOptions.parsePDF
? ["pdf"]
? ["pdf", "image"]
: []
: undefined,
actions: pageOptions.actions,
Expand Down Expand Up @@ -2120,7 +2155,7 @@ export function fromV1ScrapeOptions(
parsers:
v1ScrapeOptions.parsePDF !== undefined
? v1ScrapeOptions.parsePDF
? ["pdf"]
? ["pdf", "image"]
: []
: undefined,
}),
Expand Down
26 changes: 18 additions & 8 deletions apps/api/src/lib/image-ocr-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,31 +45,41 @@ describe("imageOcrGate", () => {
mockedGetACUCTeam.mockReset();
});

it("is off without the image parser and never looks the team up", async () => {
await expect(
imageOcrGate("team", { imageOcr: true }, false)(),
).resolves.toBe(false);
await expect(imageOcrGate("team", undefined, false)()).resolves.toBe(false);
expect(mockedGetACUCTeam).not.toHaveBeenCalled();
});

it("uses the flags carried on the job without a lookup", async () => {
await expect(imageOcrGate("team", { imageOcr: true })()).resolves.toBe(
true,
);
await expect(imageOcrGate("team", null)()).resolves.toBe(false);
await expect(
imageOcrGate("team", { imageOcr: true }, true)(),
).resolves.toBe(true);
await expect(imageOcrGate("team", null, true)()).resolves.toBe(false);
expect(mockedGetACUCTeam).not.toHaveBeenCalled();
});

it("falls back to the cached team ACUC once per scrape when the job carries no flags", async () => {
mockedGetACUCTeam.mockResolvedValueOnce({
flags: { imageOcr: true },
} as Awaited<ReturnType<typeof getACUCTeam>>);
const gate = imageOcrGate("team", undefined);
const gate = imageOcrGate("team", undefined, true);
await expect(gate()).resolves.toBe(true);
await expect(gate()).resolves.toBe(true);
expect(mockedGetACUCTeam).toHaveBeenCalledTimes(1);
expect(mockedGetACUCTeam).toHaveBeenCalledWith("team");

mockedGetACUCTeam.mockResolvedValueOnce(null);
await expect(imageOcrGate("team", undefined)()).resolves.toBe(false);
await expect(imageOcrGate("team", undefined, true)()).resolves.toBe(false);
});

it("leaves image OCR off when the lookup fails or there is no team", async () => {
mockedGetACUCTeam.mockRejectedValueOnce(new Error("redis down"));
await expect(imageOcrGate("team", undefined)()).resolves.toBe(false);
await expect(imageOcrGate(undefined, undefined)()).resolves.toBe(false);
await expect(imageOcrGate("team", undefined, true)()).resolves.toBe(false);
await expect(imageOcrGate(undefined, undefined, true)()).resolves.toBe(
false,
);
});
});
24 changes: 18 additions & 6 deletions apps/api/src/lib/image-ocr-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,31 @@ export function isImageOcrEnabled(
/** Per-scrape gate: resolved lazily on first call and memoized. */
export type ImageOcrGate = () => Promise<boolean>;

const OFF: Promise<boolean> = Promise.resolve(false);

/**
* Builds the per-scrape gate. Single scrapes and parse uploads carry the
* authenticated team's flags in their internalOptions and resolve without any
* Builds the per-scrape gate: whether this request may OCR raster images.
*
* Two conditions fold into it. The request's `parsers` must include the
* `image` parser — it does by default, and a parse upload of an image counts
* regardless — and the team must have the `imageOcr` flag while the feature
* rolls out. A request that opted out is settled up front without any I/O.
*
* For the team side, single scrapes and parse uploads carry the
* authenticated team's flags in their internalOptions and resolve without
* I/O; batch-scrape and crawl jobs do not, so for those the flags come from
* the cached team ACUC. The lookup is deferred until a caller actually needs
* the answer (an image-extension URL, an image handoff, the image engine) and
* memoized, so the ordinary HTML documents that make up almost every crawl
* never pay for it. Any lookup failure keeps the pre-existing behaviour.
* the cached team ACUC. That lookup is deferred until a caller actually needs
* the answer (an image-extension URL, an image handoff, the image engine, a
* cached image document) and memoized, so the ordinary HTML documents that
* make up almost every crawl never pay for it. Any lookup failure keeps the
* pre-existing behaviour.
*/
export function imageOcrGate(
teamId: string | undefined,
teamFlags: TeamFlags | null | undefined,
requested: boolean,
): ImageOcrGate {
if (!requested) return () => OFF;
let pending: Promise<boolean> | undefined;
return () => {
pending ??= resolveImageOcrEnabled(teamId, teamFlags);
Expand Down
Loading
Loading