[subs] Generate PDFs for dashboard subscriptions on the BE (#75205)

The PDFs are laid out on either A4 or US Letter paper, portrait orientation, following the size and positioning of cards on the dashboard itself.

This is a nearly complete rendering of the dashboard, akin to what you see in the brower:
- Directly building a PDF in memory with Apache PDFBox, a low-level Java library.
- All chart types supported by static viz
    - Recently expanded to include maps and pivot tables!
- Markdown rendering, including code blocks, clickable links etc.
- Unicode support (Europe, Cyrillic, Greek, Hebrew, Arabic, Hindi, CJK and others) including RTL
- iframe cards have their target (e.g. a YouTube video) rendered as a link
- Dashboard is run as the requesting user, and can have parameter values supplied
- Parameters on dashboard and individual cards are shown with their values.
- Solid API endpoint that allows for parameter values and directly responds with the PDF.
- Reusing query results when sending charts via email or Slack along with a PDF.

---------

Co-authored-by: Nick Fitzpatrick <nick@metabase.com>
This commit is contained in:
Braden Shepherdson
2026-06-22 16:40:28 -04:00
committed by GitHub
parent 09726c60c7
commit 57991c5b67
60 changed files with 4567 additions and 245 deletions

View File

@@ -504,6 +504,7 @@
pivot
premium-features
query-processor
request
settings
slackbot
system
@@ -514,6 +515,8 @@
util}
:model-exports #{:model/Channel :model/ChannelTemplate}
:model-imports #{:model/Dashboard
:model/DashboardCard
:model/DashboardTab
:model/Database
:model/PulseChannel
:model/User}}

View File

@@ -58,7 +58,7 @@
com.snowplowanalytics/snowplow-java-tracker ^:antq/exclude ; 2+ depend on apache httpclient 5.x, but saml and clj-http depend on 4.x
{:mvn/version "1.0.1"} ; Snowplow analytics
com.sun.mail/jakarta.mail ^:antq/exclude ; 2+ changes imports from javax.mail to jakarta.mail
{:mvn/version "1.6.8"} ; SMTP lib, required by postal
{:mvn/version "1.6.8"} ; SMTP lib, required by postal
com.taoensso/encore {:mvn/version "3.160.1"} ; Utilities library (used by Nippy)
com.taoensso/nippy {:mvn/version "3.6.2"} ; Fast serialization (i.e., GZIP) library for Clojure
com.vladsch.flexmark/flexmark {:mvn/version "0.64.8"} ; Markdown parsing
@@ -113,7 +113,7 @@
org.apache.commons/commons-compress {:mvn/version "1.28.0"} ; compression utils
org.apache.commons/commons-lang3 {:mvn/version "3.20.0"} ; helper methods for working with java.lang stuff
org.apache.datasketches/datasketches-java ^:antq/exclude ; Sketching algorithms (used for fingerprinting)
{:mvn/version "7.0.1"} ; version is fixed to ensure JDK21 compatibility.
{:mvn/version "7.0.1"} ; version is fixed to ensure JDK21 compatibility.
org.apache.httpcomponents.client5/httpclient5 {:mvn/version "5.4.4"} ; pinned: ClickHouse JDBC 0.9.7 needs httpcore5 5.3.4+; must pair with httpclient5 5.4.x
org.apache.httpcomponents.core5/httpcore5 {:mvn/version "5.3.5"} ; pinned: ClickHouse JDBC 0.9.7 requires URIBuilder.optimize() added in 5.3
org.apache.httpcomponents.core5/httpcore5-h2 {:mvn/version "5.3.5"} ; pinned: must match httpcore5 version
@@ -126,6 +126,7 @@
{:mvn/version "2.25.4"} ; allows the custom json logging format
org.apache.logging.log4j/log4j-slf4j2-impl
{:mvn/version "2.25.4"} ; allows the slf4j2 API to work with log4j 2
org.apache.pdfbox/pdfbox {:mvn/version "3.0.5"} ; PDF generation (backend dashboard PDF prototype)
org.apache.poi/poi {:mvn/version "5.5.1"} ; Work with Office documents (e.g. Excel spreadsheets) -- newer version than one specified by Docjure
org.apache.poi/poi-ooxml {:mvn/version "5.5.1"
:exclusions [org.bouncycastle/bcpkix-jdk15on
@@ -211,7 +212,7 @@
weavejester/dependency {:mvn/version "1.0.0"} ; Dependency graphs and topological sorting
xerces/xercesImpl {:mvn/version "2.12.2"}} ; SAX2 parser, transient dependency of batik
;; !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
;; !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
;; !! PLEASE KEEP NEW DEPENDENCIES ABOVE ALPHABETICALLY ORGANIZED AND ADD COMMENTS EXPLAINING THEM. !!
;; !! *PLEASE DO NOT* ADD NEW ONES TO THE BOTTOM OF THE LIST. !!
;; !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
@@ -941,7 +942,7 @@
{:exec-args {:only ["enterprise/backend/test/metabase_enterprise/transforms"
"enterprise/backend/test/metabase_enterprise/transforms_python"]}}
;; test all MBQL related stuff: Lib, the legacy shared `metabase.legacy-mbql` code, and the QP
;; test all MBQL related stuff: Lib, the legacy shared `metabase.legacy-mbql` code, and the QP
;;
;; clj -X:dev:ee:ee-dev:test:test/mbql
:test/mbql
@@ -1001,4 +1002,4 @@
"-Dmb.db.connection.uri=jdbc:mysql://localhost:3307/metabase_test?user=root&password="]}}}
;; TODO -- consider creating an alias that includes the `./bin` build-drivers & release code as well so we can work
;; on them all from a single REPL process.
;; on them all from a single REPL process.

View File

@@ -713,7 +713,9 @@ describe("issue 30314", () => {
H.sidebar().within(() => {
cy.findByText("Email it").click();
cy.findByLabelText("Attach results").should("not.be.checked").click();
cy.findByLabelText("Attach results")
.should("not.be.checked")
.click({ force: true }); // Input is placed behind the lable due to tooltip in label
cy.findByLabelText("Questions to attach")
.should("not.be.checked")
.click();
@@ -1034,7 +1036,9 @@ describe("issue 49525", { tags: "@external" }, () => {
H.sidebar().within(() => {
// Click this just to close the popover that is blocking the "Send email now" button
cy.findByText("To:").click();
cy.findByLabelText("Attach results").click();
cy.findByLabelText("Attach results")
.should("not.be.checked")
.click({ force: true }); // Input is placed behind the lable due to tooltip in label
cy.findByText("Keep the data pivoted").click();
cy.findByText("Questions to attach").click();
});

View File

@@ -331,26 +331,21 @@ describe("scenarios > dashboard > subscriptions", () => {
it("should persist attachments for dashboard subscriptions (metabase#14117)", () => {
assignRecipient();
// This is extremely fragile
// TODO: update test once changes from `https://github.com/metabase/metabase/pull/14121` are merged into `master`
cy.findByLabelText("Attach results").click();
// eslint-disable-next-line metabase/no-unscoped-text-selectors -- deprecated usage
cy.findByText("Questions to attach").click();
clickButton("Done");
// eslint-disable-next-line metabase/no-unscoped-text-selectors -- deprecated usage
cy.findByText("Subscriptions");
// eslint-disable-next-line metabase/no-unscoped-text-selectors -- deprecated usage
cy.findByText("Emailed hourly").click();
// eslint-disable-next-line metabase/no-unscoped-text-selectors -- deprecated usage
cy.findByText("Delete this subscription").scrollIntoView();
// eslint-disable-next-line metabase/no-unscoped-text-selectors -- deprecated usage
cy.findByText("Questions to attach");
cy.findAllByRole("listitem")
.contains("Orders")
.closest("li")
.within(() => {
cy.findByRole("checkbox").should("be.checked");
});
H.sidebar().within(() => {
cy.findByLabelText("Attach results")
.should("not.be.checked")
.click({ force: true }); // Input is placed behind the lable due to tooltip in label
cy.findByText("Questions to attach").click();
clickButton("Done");
cy.findByText("Subscriptions").should("exist");
cy.findByText("Emailed hourly").click();
cy.findByText("Delete this subscription").scrollIntoView();
cy.findByText("Questions to attach").should("be.visible");
cy.findByLabelText("Orders").should("be.checked");
});
});
it("should localize schedule type in the delete-confirmation modal", () => {
@@ -379,7 +374,9 @@ describe("scenarios > dashboard > subscriptions", () => {
it("should send only attachments without email content when 'Send only attachments' is enabled", () => {
assignRecipient();
cy.findByLabelText("Attach results").click();
cy.findByLabelText("Attach results")
.should("not.be.checked")
.click({ force: true }); // Input is placed behind the lable due to tooltip in label
cy.findByLabelText("Questions to attach").click();
cy.findByLabelText("Send only attachments").click();
cy.findByLabelText("Send only attachments").should("be.checked");

View File

@@ -9,7 +9,6 @@ import {
} from "metabase/common/components/SchedulePicker";
import { SendTestPulse } from "metabase/common/components/SendTestPulse";
import { Sidebar } from "metabase/common/components/Sidebar";
import { Toggle } from "metabase/common/components/Toggle";
import CS from "metabase/css/core/index.css";
import { isEmbeddingSdk } from "metabase/embedding-sdk/config";
import { EmailAttachmentPicker } from "metabase/notifications/EmailAttachmentPicker";
@@ -19,7 +18,7 @@ import { dashboardPulseIsValid } from "metabase/pulse";
import { useSelector } from "metabase/redux";
import type { DraftDashboardSubscription } from "metabase/redux/store";
import { canAccessSettings, getUser } from "metabase/selectors/user";
import { Icon, Title } from "metabase/ui";
import { Icon, Stack, Switch, Text, Title } from "metabase/ui";
import type { UiParameter } from "metabase-lib/v1/parameters/types";
import {
type Channel,
@@ -31,6 +30,7 @@ import {
type User,
} from "metabase-types/api";
import S from "./AddEditSidebar.module.css";
import { CaveatMessage } from "./CaveatMessage";
import DefaultParametersSection from "./DefaultParametersSection";
import { DeleteSubscriptionAction } from "./DeleteSubscriptionAction";
@@ -85,10 +85,26 @@ export const AddEditEmailSidebar = ({
const currentUser = useSelector(getUser);
// Return true if the results of all cards can be downloaded
const allowDownload = pulse.cards?.every(
const allowDownload = pulse.cards.every(
(card) => card.download_perms !== DataPermissionValue.NONE,
);
// Whether to attach a server-rendered PDF of the whole dashboard. Stored per-channel in
// `details.include_pdf` (the same place as `attachment_only`); the BE reads it on the email path.
const includePdf =
allowDownload &&
pulse.channels.some((channel) => !!channel.details?.include_pdf);
const handleToggleIncludePdf = (checked: boolean) => {
setPulse({
...pulse,
channels: pulse.channels.map((channel) => ({
...channel,
details: { ...channel.details, include_pdf: checked },
})),
});
};
useEffect(() => {
if (isEmbeddingSdk()) {
onChannelPropertyChange("recipients", [currentUser]);
@@ -171,37 +187,43 @@ export const AddEditEmailSidebar = ({
parameters={parameters}
/>
)}
<div
className={cx(
CS.textBold,
CS.py3,
CS.flex,
CS.justifyBetween,
CS.alignCenter,
CS.borderTop,
)}
>
<Title order={4}>{t`Don't send if there aren't results`}</Title>
<Toggle
value={pulse.skip_if_empty || false}
<Stack gap="md" py="lg" className={CS.borderTop}>
<Switch
checked={pulse.skip_if_empty || false}
onChange={toggleSkipIfEmpty}
label={
<Text fw="bold">{t`Don't send if there aren't results`}</Text>
}
labelPosition="left"
classNames={{
body: S.SwitchBody,
}}
/>
</div>
<EmailAttachmentPicker
cards={pulse.cards}
pulse={pulse}
setPulse={setPulse}
allowDownload={allowDownload}
/>
<Switch
checked={includePdf}
onChange={(e) => handleToggleIncludePdf(e.target.checked)}
disabled={!allowDownload}
classNames={{
body: S.SwitchBody,
input: S.SwitchInput,
}}
label={<Text fw="bold">{t`Attach a PDF of the dashboard`}</Text>}
labelPosition="left"
/>
<EmailAttachmentPicker
cards={pulse.cards}
pulse={pulse}
setPulse={setPulse}
allowDownload={allowDownload}
/>
</Stack>
{pulse.id != null && (
<DeleteSubscriptionAction
pulse={pulse}
handleArchive={handleArchive}
/>
)}
<div className={cx(CS.p2, CS.mtAuto, CS.textSmall, CS.textMedium)}>
{t`Charts in subscriptions may look slightly different from charts in dashboards.`}
</div>
</div>
</Sidebar>
);

View File

@@ -0,0 +1,7 @@
.SwitchBody {
justify-content: space-between;
}
.SwitchInput {
z-index: -1; /* This allows icons with tooltips in the label */
}

View File

@@ -8,23 +8,24 @@ import {
} from "metabase/common/components/SchedulePicker";
import { SendTestPulse } from "metabase/common/components/SendTestPulse";
import { Sidebar } from "metabase/common/components/Sidebar";
import { Toggle } from "metabase/common/components/Toggle";
import CS from "metabase/css/core/index.css";
import { SlackChannelField } from "metabase/notifications/channels/SlackChannelField";
import { PLUGIN_DASHBOARD_SUBSCRIPTION_PARAMETERS_SECTION_OVERRIDE } from "metabase/plugins";
import { dashboardPulseIsValid } from "metabase/pulse";
import type { DraftDashboardSubscription } from "metabase/redux/store";
import { Icon, Title } from "metabase/ui";
import { Icon, Stack, Switch, Text, Title } from "metabase/ui";
import type { UiParameter } from "metabase-lib/v1/parameters/types";
import type {
Channel,
ChannelApiResponse,
ChannelSpec,
ChannelSpecs,
Dashboard,
ScheduleSettings,
import {
type Channel,
type ChannelApiResponse,
type ChannelSpec,
type ChannelSpecs,
type Dashboard,
DataPermissionValue,
type ScheduleSettings,
} from "metabase-types/api";
import S from "./AddEditSidebar.module.css";
import { CaveatMessage } from "./CaveatMessage";
import DefaultParametersSection from "./DefaultParametersSection";
import { DeleteSubscriptionAction } from "./DeleteSubscriptionAction";
@@ -74,6 +75,21 @@ export const AddEditSlackSidebar = ({
formInput.channels as ChannelSpecs,
);
// Return true if the results of all cards can be downloaded
const allowDownload = pulse.cards?.every(
(card) => card.download_perms !== DataPermissionValue.NONE,
);
// Whether to share a server-rendered PDF of the whole dashboard to the channel.
const includePdf = !!allowDownload && !!channel.details?.include_pdf;
const handleToggleIncludePdf = (checked: boolean) => {
onChannelPropertyChange("details", {
...channel.details,
include_pdf: checked,
});
};
return (
<Sidebar
isCloseDisabled={!isValid}
@@ -140,31 +156,38 @@ export const AddEditSlackSidebar = ({
parameters={parameters}
/>
)}
<div
className={cx(
CS.textBold,
CS.py2,
CS.flex,
CS.justifyBetween,
CS.alignCenter,
CS.borderTop,
)}
>
<Title order={4}>{t`Don't send if there aren't results`}</Title>
<Toggle
value={pulse.skip_if_empty || false}
<Stack gap="md" py="lg" className={CS.borderTop}>
<Switch
checked={pulse.skip_if_empty || false}
onChange={toggleSkipIfEmpty}
label={
<Text fw="bold">{t`Don't send if there aren't results`}</Text>
}
labelPosition="left"
classNames={{
body: S.SwitchBody,
}}
/>
</div>
<Switch
aria-label={t`Send dashboard as PDF`}
checked={includePdf}
onChange={(e) => handleToggleIncludePdf(e.target.checked)}
disabled={!allowDownload}
labelPosition="left"
classNames={{
body: S.SwitchBody,
input: S.SwitchInput,
}}
label={<Text fw="bold">{t`Send dashboard as PDF`}</Text>}
/>
</Stack>
{pulse.id != null && (
<DeleteSubscriptionAction
pulse={pulse}
handleArchive={handleArchive}
/>
)}
<div className={cx(CS.p2, CS.mtAuto, CS.textSmall, CS.textMedium)}>
{t`Charts in subscriptions may look slightly different from charts in dashboards.`}
</div>
</div>
</Sidebar>
);

View File

@@ -253,6 +253,31 @@ describe("DashboardSubscriptionsSidebar", () => {
expect(hasBasicFilterOptions(screen)).toBe(true);
});
it("should send the include_pdf channel detail to the backend when the PDF switch is on", async () => {
setup({ isAdmin: false, email: false, slack: true });
await screen.findByText("Send this dashboard to Slack");
// Pick a channel so the subscription is valid and can be sent
await userEvent.type(
await screen.findByPlaceholderText("Pick a user or channel..."),
"#general",
);
await userEvent.click(
await screen.findByRole("switch", {
name: /Send dashboard as PDF/,
}),
);
await userEvent.click(await screen.findByText("Send to Slack now"));
const lastCall = fetchMock.callHistory.lastCall("path:/api/pulse/test");
const payload = await lastCall?.request?.json();
expect(payload.channels[0].details.channel).toBe("#general");
expect(payload.channels[0].details.include_pdf).toBe(true);
});
});
describe("Email Subscription sidebar", () => {
@@ -286,5 +311,35 @@ describe("DashboardSubscriptionsSidebar", () => {
expect(payload.cards).toHaveLength(1);
expect(payload.cards[0].id).toEqual(dashcard.id);
});
it("should persist the include_pdf channel detail when the PDF switch is toggled on", async () => {
setup();
await userEvent.click(await screen.findByText("Email it"));
await screen.findByText("Email this dashboard");
const pdfSwitch = await screen.findByRole("switch", {
name: /Attach a PDF of the dashboard/,
});
expect(pdfSwitch).not.toBeChecked();
await userEvent.click(pdfSwitch);
expect(pdfSwitch).toBeChecked();
// Confirm the flag rides along in the channel details on send
await userEvent.click(
await screen.findByPlaceholderText(
"Enter user names or email addresses",
),
);
await userEvent.click(
await screen.findByText(`${user.first_name} ${user.last_name}`),
);
await userEvent.click(await screen.findByText("Send email now"));
const lastCall = fetchMock.callHistory.lastCall("path:/api/pulse/test");
const payload = await lastCall?.request?.json();
expect(payload.channels[0].details.include_pdf).toBe(true);
});
});
});

View File

@@ -0,0 +1,7 @@
.AttachmentSwitchBody {
justify-content: space-between;
}
.AttachmentSwitchInput {
z-index: -1;
}

View File

@@ -15,9 +15,11 @@ import { Toggle } from "metabase/common/components/Toggle";
import type { ExportFormat } from "metabase/common/types/export";
import CS from "metabase/css/core/index.css";
import type { DraftDashboardSubscription } from "metabase/redux/store";
import { Box, Checkbox, Group, Icon, Text, Tooltip } from "metabase/ui";
import { Box, Checkbox, Group, Icon, Switch, Text, Tooltip } from "metabase/ui";
import type { SubscriptionSupportingCard } from "metabase-types/api";
import S from "./EmailAttachmentPicker.module.css";
const DEFAULT_ATTACHMENT_TYPE: AttachmentType = "csv";
type AttachmentType = "csv" | "xlsx";
@@ -395,35 +397,40 @@ export function EmailAttachmentPicker({
return (
<div>
<Group
className={CS.borderTop}
justify="space-between"
pt="1.5rem"
pb="1.5rem"
opacity={canAttachFiles ? 1 : 0.6}
>
<Group gap="0">
<Text fw="bold">{t`Attach results as files`}</Text>
<Icon
name="info"
c="text-secondary"
ml="0.5rem"
size={12}
tooltip={
disabledReason ||
t`Attachments can contain up to 2,000 rows of data.`
<Tooltip label={disabledReason} disabled={!disabledReason}>
<Box>
<Switch
label={
<Group gap={0}>
<Text
fw="bold"
c={disabledReason ? "text-secondary" : "text-primary"}
>{t`Attach CSV/XLSX with results`}</Text>
<Icon
name="info"
c="text-secondary"
ml="0.5rem"
size={12}
tooltip={
!disabledReason
? t`Attachments can contain up to 2,000 rows of data.`
: undefined
}
/>
</Group>
}
/>
</Group>
<Tooltip label={disabledReason} disabled={!disabledReason}>
<Toggle
aria-label={t`Attach results`}
value={isEnabled && canAttachFiles}
onChange={toggleAttach}
checked={isEnabled && canAttachFiles}
onChange={(e) => toggleAttach(e.target.checked)}
disabled={!canAttachFiles}
labelPosition="left"
classNames={{
body: S.AttachmentSwitchBody,
input: S.AttachmentSwitchInput,
}}
/>
</Tooltip>
</Group>
</Box>
</Tooltip>
{isEnabled && canAttachFiles && (
<div>
<Box py="1rem">

View File

@@ -4,6 +4,7 @@ import { t } from "ttag";
import type { StaticChartProps } from "metabase/static-viz/components/StaticVisualization";
import { sanitizeSvgForBatik } from "metabase/static-viz/lib/svg";
import { getChartHeight } from "metabase/static-viz/lib/utils";
import { registerEChartsModules } from "metabase/visualizations/echarts";
import { getChartLayout } from "metabase/visualizations/echarts/cartesian/layout";
import { getCartesianChartModel } from "metabase/visualizations/echarts/cartesian/model";
@@ -34,14 +35,8 @@ export const ComboChart = ({
height = HEIGHT,
isStorybook = false,
hasDevWatermark = false,
fitWithinBounds = false,
}: StaticChartProps) => {
const chart = init(null, null, {
renderer: "svg",
ssr: true,
width,
height,
});
const chartModel = getCartesianChartModel(
rawSeries,
settings,
@@ -60,12 +55,21 @@ export const ComboChart = ({
isReversed,
});
const chartHeight = getChartHeight({ fitWithinBounds, legendHeight, height });
const chart = init(null, null, {
renderer: "svg",
ssr: true,
width,
height: chartHeight,
});
const chartLayout = getChartLayout(
chartModel,
settings,
false,
width,
height,
chartHeight,
renderingContext,
);
@@ -88,7 +92,7 @@ export const ComboChart = ({
settings,
);
const totalHeight = height + legendHeight;
const totalHeight = fitWithinBounds ? height : height + legendHeight;
return (
<>

View File

@@ -8,9 +8,26 @@ import { getPieChartModel } from "metabase/visualizations/echarts/pie/model";
import { getPieChartOption } from "metabase/visualizations/echarts/pie/option";
import Watermark from "../../watermark.svg?component";
import { Legend } from "../Legend";
import { LEGEND_ITEM_MARGIN_RIGHT_GRID } from "../Legend/constants";
import type { StaticChartProps } from "../StaticVisualization";
import { getPieChartLegend } from "./legend";
import { getPieChartLegend, getPieChartSideLegend } from "./legend";
function renderPieSvg(
option: ReturnType<typeof getPieChartOption>,
side: number,
isStorybook?: boolean,
) {
const chart = init(null, null, {
renderer: "svg",
ssr: true,
width: side,
height: side,
});
chart.setOption(option);
return sanitizeSvgForBatik(chart.renderToSVGString(), isStorybook ?? false);
}
export function PieChart({
rawSeries,
@@ -18,6 +35,8 @@ export function PieChart({
settings,
isStorybook,
hasDevWatermark = false,
width,
height,
}: StaticChartProps) {
const chartModel = getPieChartModel(
rawSeries,
@@ -26,6 +45,66 @@ export function PieChart({
renderingContext,
);
const formatters = getPieChartFormatters(chartModel, settings);
// When an explicit box is provided and it's square-or-wider, render the legend as a single
// column to the LEFT of the circle (and enlarge the circle), matching the app's responsive
// pie layout. Otherwise keep the default square-circle-with-legend-below layout (which is
// also what subscription emails use, since they don't pass width/height).
if (width != null && height != null && width >= height) {
const totalW = width;
const totalH = height;
const gap = DIMENSIONS.padding.side;
// Reserve room for the legend, but never more than ~45% of the width, and keep at least
// half the height for the circle.
const maxLegendW = Math.min(
totalW * 0.45,
Math.max(0, totalW - totalH * 0.5),
);
const { items, legendHeight, legendWidth } = getPieChartSideLegend(
chartModel,
formatters,
settings,
maxLegendW,
);
const legendColumnWidth = legendWidth > 0 ? legendWidth + gap : 0;
const side = Math.max(0, Math.min(totalH, totalW - legendColumnWidth));
const option = getPieChartOption(
chartModel,
formatters,
settings,
renderingContext,
side,
);
const chartSvg = renderPieSvg(option, side, isStorybook);
return (
<svg xmlns="http://www.w3.org/2000/svg" width={totalW} height={totalH}>
<Group
left={legendColumnWidth}
top={(totalH - side) / 2}
dangerouslySetInnerHTML={{ __html: chartSvg }}
></Group>
<Legend
items={items}
top={Math.max(0, (totalH - legendHeight) / 2)}
left={gap}
legendItemMarginRight={LEGEND_ITEM_MARGIN_RIGHT_GRID}
/>
{hasDevWatermark && (
<Watermark
x="0"
y="0"
height={totalH}
width={totalW}
preserveAspectRatio="xMinYMin slice"
fill={renderingContext.getColor("text-secondary")}
opacity={0.2}
/>
)}
</svg>
);
}
const option = getPieChartOption(
chartModel,
formatters,
@@ -33,38 +112,27 @@ export function PieChart({
renderingContext,
DIMENSIONS.maxSideLength,
);
const { legendHeight, Legend } = getPieChartLegend(
const { legendHeight, Legend: BottomLegend } = getPieChartLegend(
chartModel,
formatters,
settings,
);
const chartSvg = renderPieSvg(option, DIMENSIONS.maxSideLength, isStorybook);
const chart = init(null, null, {
renderer: "svg",
ssr: true,
width: DIMENSIONS.maxSideLength,
height: DIMENSIONS.maxSideLength,
});
chart.setOption(option);
const chartSvg = sanitizeSvgForBatik(
chart.renderToSVGString(),
isStorybook ?? false,
);
const height =
const svgHeight =
DIMENSIONS.maxSideLength + DIMENSIONS.padding.legend + legendHeight;
const width = DIMENSIONS.maxSideLength;
const svgWidth = DIMENSIONS.maxSideLength;
return (
<svg xmlns="http://www.w3.org/2000/svg" width={width} height={height}>
<svg xmlns="http://www.w3.org/2000/svg" width={svgWidth} height={svgHeight}>
<Group dangerouslySetInnerHTML={{ __html: chartSvg }}></Group>
<Legend />
<BottomLegend />
{hasDevWatermark && (
<Watermark
x="0"
y="0"
height={height}
width={width}
height={svgHeight}
width={svgWidth}
preserveAspectRatio="xMinYMin slice"
fill={renderingContext.getColor("text-secondary")}
opacity={0.2}

View File

@@ -1,3 +1,5 @@
import { measureTextWidth } from "metabase/static-viz/lib/text";
import type { LegendItem } from "metabase/visualizations/echarts/cartesian/model/types";
import { DIMENSIONS } from "metabase/visualizations/echarts/pie/constants";
import type { PieChartFormatters } from "metabase/visualizations/echarts/pie/format";
import type { PieChartModel } from "metabase/visualizations/echarts/pie/model/types";
@@ -5,8 +7,52 @@ import { getArrayFromMapValues } from "metabase/visualizations/echarts/pie/util"
import type { ComputedVisualizationSettings } from "metabase/visualizations/types";
import { Legend } from "../Legend";
import { LEGEND_ITEM_MARGIN_RIGHT_GRID } from "../Legend/constants";
import { calculateLegendRowsWithColumns } from "../Legend/utils";
import {
DEFAULT_LEGEND_FONT_SIZE,
DEFAULT_LEGEND_FONT_WEIGHT,
LEGEND_CIRCLE_MARGIN_RIGHT,
LEGEND_CIRCLE_SIZE,
LEGEND_ITEM_MARGIN_RIGHT_GRID,
} from "../Legend/constants";
import {
calculateLegendRows,
calculateLegendRowsWithColumns,
} from "../Legend/utils";
function getPieLegendItems(
chartModel: PieChartModel,
formatters: PieChartFormatters,
settings: ComputedVisualizationSettings,
): LegendItem[] {
const pieChartVisibility = settings["pie.percent_visibility"];
return getArrayFromMapValues(chartModel.sliceTree)
.filter((s) => s.includeInLegend)
.map((s) => ({
name: s.name,
percent:
pieChartVisibility === "legend" || pieChartVisibility === "both"
? formatters.formatPercent(s.normalizedPercentage, "legend")
: undefined,
color: s.color,
key: String(s.key),
}));
}
function measureLegendItemWidth(item: LegendItem): number {
const fontSize = DEFAULT_LEGEND_FONT_SIZE;
const fontWeight = DEFAULT_LEGEND_FONT_WEIGHT;
const percentWidth =
item.percent != null
? measureTextWidth(item.percent, fontSize, fontWeight)
: 0;
return (
LEGEND_CIRCLE_SIZE +
LEGEND_CIRCLE_MARGIN_RIGHT +
measureTextWidth(item.name, fontSize, fontWeight) +
percentWidth
);
}
export function getPieChartLegend(
chartModel: PieChartModel,
@@ -18,20 +64,7 @@ export function getPieChartLegend(
width: legendWidth,
items,
} = calculateLegendRowsWithColumns({
items: getArrayFromMapValues(chartModel.sliceTree)
.filter((s) => s.includeInLegend)
.map((s) => {
return {
name: s.name,
percent:
settings["pie.percent_visibility"] === "legend" ||
settings["pie.percent_visibility"] === "both"
? formatters.formatPercent(s.normalizedPercentage, "legend")
: undefined,
color: s.color,
key: String(s.key),
};
}),
items: getPieLegendItems(chartModel, formatters, settings),
width: DIMENSIONS.maxSideLength,
horizontalPadding: DIMENSIONS.padding.side,
});
@@ -53,3 +86,33 @@ export function getPieChartLegend(
),
};
}
/**
* Build a single-column (vertical) legend for placing to the side of the circle. `maxWidth`
* caps the column width; item names are truncated to fit. Returns the positioned items plus
* the rendered legend block dimensions.
*/
export function getPieChartSideLegend(
chartModel: PieChartModel,
formatters: PieChartFormatters,
settings: ComputedVisualizationSettings,
maxWidth: number,
) {
const items = getPieLegendItems(chartModel, formatters, settings);
if (items.length === 0) {
return { items: [], legendHeight: 0, legendWidth: 0 };
}
// Budget the column at (at most) the widest item so two items never share a row -> one column.
const widest = Math.max(...items.map(measureLegendItemWidth));
const budget = Math.max(1, Math.min(widest, maxWidth));
const {
items: positioned,
height,
width,
} = calculateLegendRows({
items,
width: budget,
horizontalPadding: 0,
});
return { items: positioned, legendHeight: height, legendWidth: width };
}

View File

@@ -2,6 +2,7 @@ import { Group } from "@visx/group";
import type { StaticChartProps } from "metabase/static-viz/components/StaticVisualization";
import { measureTextWidth } from "metabase/static-viz/lib/text";
import { getChartHeight } from "metabase/static-viz/lib/utils";
import type { FontStyle, TextWidthMeasurer } from "metabase/utils/measure-text";
import { extractRemappedColumns } from "metabase/visualizations";
import { getChartGoal } from "metabase/visualizations/lib/settings/goal";
@@ -59,6 +60,7 @@ export const StaticRowChart = ({
width = WIDTH,
height = HEIGHT,
hasDevWatermark = false,
fitWithinBounds = false,
}: StaticChartProps) => {
const data = extractRemappedColumns(
rawSeries[0].data,
@@ -100,7 +102,9 @@ export const StaticRowChart = ({
});
const legendHeight = legend != null ? legend.height + CHART_PADDING : 0;
const fullChartHeight = height + legendHeight;
const chartHeight = getChartHeight({ fitWithinBounds, legendHeight, height });
const fullChartHeight = fitWithinBounds ? height : height + legendHeight;
return (
<svg
@@ -120,7 +124,7 @@ export const StaticRowChart = ({
<Group top={legendHeight}>
<RowChart
width={width}
height={height}
height={chartHeight}
data={groupedData}
trimData={trimData}
series={series}

View File

@@ -3,6 +3,7 @@ import { init } from "echarts/core";
import type { StaticChartProps } from "metabase/static-viz/components/StaticVisualization";
import { sanitizeSvgForBatik } from "metabase/static-viz/lib/svg";
import { getChartHeight } from "metabase/static-viz/lib/utils";
import { registerEChartsModules } from "metabase/visualizations/echarts";
import { getChartLayout } from "metabase/visualizations/echarts/cartesian/layout";
import { getLegendItems } from "metabase/visualizations/echarts/cartesian/model/legend";
@@ -27,9 +28,8 @@ export function ScatterPlot({
height = HEIGHT,
isStorybook = false,
hasDevWatermark = false,
fitWithinBounds = false,
}: StaticChartProps) {
const chart = init(null, null, { renderer: "svg", ssr: true, width, height });
const chartModel = getScatterPlotModel(
rawSeries,
settings,
@@ -46,12 +46,21 @@ export function ScatterPlot({
verticalPadding: LEGEND_PADDING,
});
const chartHeight = getChartHeight({ fitWithinBounds, legendHeight, height });
const chart = init(null, null, {
renderer: "svg",
ssr: true,
width,
height: chartHeight,
});
const chartLayout = getChartLayout(
chartModel,
settings,
false,
width,
height,
chartHeight,
renderingContext,
);
@@ -73,7 +82,7 @@ export function ScatterPlot({
<svg
xmlns="http://www.w3.org/2000/svg"
width={width}
height={height + legendHeight}
height={fitWithinBounds ? height : height + legendHeight}
>
<Legend items={legendLayoutItems} />
<Group top={legendHeight}>

View File

@@ -22,6 +22,9 @@ export const StaticVisualization = ({
renderingContext,
isStorybook,
hasDevWatermark,
width,
height,
fitWithinBounds,
}: StaticVisualizationProps) => {
const display = rawSeries[0].card.display;
const transformedSeries = getVisualizationTransformed(rawSeries).series;
@@ -32,6 +35,9 @@ export const StaticVisualization = ({
renderingContext,
isStorybook,
hasDevWatermark,
width,
height,
fitWithinBounds,
};
switch (display) {

View File

@@ -12,4 +12,6 @@ export interface StaticChartProps {
height?: number;
isStorybook?: boolean;
hasDevWatermark?: boolean;
// When true, width/height are the exact output box: charts fit their legend inside it
fitWithinBounds?: boolean;
}

View File

@@ -39,7 +39,7 @@ export function WaterfallChart({
);
const option = getWaterfallChartOption(
chartModel,
WIDTH,
width,
chartLayout,
null,
[],

View File

@@ -53,6 +53,12 @@ export type RenderChartOptions = {
applicationColors: ColorPalette;
customFormatting: FormattingSettings;
startOfWeek: DayOfWeekId | null | undefined;
// Explicit pixel dimensions for the chart. Use fitWithinBounds to have height include
// chart legends
width?: number;
height?: number;
// When true, width/height are treated as the exact output box
fitWithinBounds?: boolean;
};
type RenderChartDashcardSettings = DashCardVisualizationSettings & {
@@ -207,6 +213,9 @@ export function RenderChart(
rawSeries={rawSeriesWithRemappings}
renderingContext={renderingContext}
hasDevWatermark={hasDevWatermark}
width={options.width}
height={options.height}
fitWithinBounds={options.fitWithinBounds}
/>,
);
}

View File

@@ -0,0 +1,9 @@
export const getChartHeight = ({
fitWithinBounds,
height,
legendHeight,
}: {
fitWithinBounds: boolean;
height: number;
legendHeight: number;
}) => (fitWithinBounds ? Math.max(height - legendHeight, 1) : height);

View File

@@ -136,6 +136,9 @@ export interface StaticVisualizationProps {
renderingContext: RenderingContext;
isStorybook?: boolean;
hasDevWatermark?: boolean;
width?: number;
height?: number;
fitWithinBounds?: boolean;
}
export interface VisualizationProps {

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -2,6 +2,7 @@
(:require
[buddy.core.codecs :as codecs]
[buddy.core.hash :as buddy-hash]
[clojure.java.io :as io]
[clojure.string :as str]
[hiccup.core :refer [html]]
[medley.core :as m]
@@ -30,7 +31,9 @@
[metabase.util.malli.schema :as ms]
[metabase.util.markdown :as markdown]
[metabase.util.ui-logic :as ui-logic]
[ring.util.codec :as codec]))
[ring.util.codec :as codec])
(:import
(java.io File)))
(set! *warn-on-reflection* true)
@@ -272,8 +275,30 @@
;; Dashboard Subscriptions ;;
;; ------------------------------------------------------------------------------------------------;;
(defn- dashboard-pdf-attachment
"Render the whole dashboard to a PDF email attachment, or `nil` if rendering fails."
[dashboard-id dashboard-name creator-id parameters]
(try
(let [pdf-bytes (channel.render/render-dashboard-to-pdf dashboard-id creator-id (vec parameters))
temp-file (doto (File/createTempFile "metabase_dashboard_" ".pdf")
(.deleteOnExit))]
(with-open [os (io/output-stream temp-file)]
(.write os ^bytes pdf-bytes))
{:type :attachment
:content-type "application/pdf"
:file-name (-> dashboard-name
(some-> str/trim)
not-empty
(or "dashboard")
(str ".pdf"))
:content (.. temp-file toURI toURL)
:description (format "PDF of dashboard '%s'" (or dashboard-name "dashboard"))})
(catch Throwable e
(log/error e "Error rendering dashboard subscription PDF; skipping PDF attachment")
nil)))
(mu/defmethod channel/render-notification [:channel/email :notification/dashboard] :- [:sequential EmailMessage]
[_channel-type {:keys [payload payload_type creator_id] :as notification-payload} {:keys [template recipients attachment_only]}]
[_channel-type {:keys [payload payload_type creator_id] :as notification-payload} {:keys [template recipients attachment_only include_pdf]}]
(let [{:keys [dashboard_parts
dashboard_subscription
parameters
@@ -310,9 +335,11 @@
(assoc-attachment-booleans (:dashboard_subscription_dashcards dashboard_subscription) dashboard_parts))
icon-attachment (make-message-attachment (first (icon-bundle :dashboard)))
card-attachments (map make-message-attachment merged-attachments)
pdf-attachment (when include_pdf
(dashboard-pdf-attachment (:id dashboard) (:name dashboard) creator_id parameters))
attachments (cond-> (into [icon-attachment] result-attachments)
(not attachment_only)
(concat card-attachments))
(not attachment_only) (concat card-attachments)
pdf-attachment (concat [pdf-attachment]))
dashboard-content (if-not attachment_only
(str "<div>" (str/join html-contents) "</div>")
"<p>Dashboard content available in attached files</p>")

View File

@@ -137,13 +137,25 @@
(def ^:private SlackMessage
[:map {:closed true}
[:channel :string]
[:blocks [:sequential :map]]])
[:channel :string]
[:blocks [:sequential :map]]
[:pdf {:optional true} [:maybe [:map
[:bytes bytes?]
[:filename :string]
[:comment {:optional true} [:maybe :string]]]]]])
(mu/defmethod channel/send! :channel/slack
[_channel {:keys [channel blocks]} :- SlackMessage]
[_channel {:keys [channel blocks pdf]} :- SlackMessage]
(doseq [block-chunk (partition-all 50 blocks)]
(slack/post-chat-message! {:channel channel :blocks block-chunk})))
(slack/post-chat-message! {:channel channel :blocks block-chunk}))
(when pdf
;; Share the PDF as one message (its caption is the file's `initial_comment`); on failure, post just the caption.
(try
(slack/upload-file-to-channel! (:bytes pdf) (:filename pdf) channel (:comment pdf))
(catch Throwable e
(log/error e "Error sharing dashboard subscription PDF to Slack; posting summary without the PDF")
(when-let [comment (:comment pdf)]
(slack/post-chat-message! {:channel channel :text comment}))))))
;; ------------------------------------------------------------------------------------------------;;
;; System Event Notifications ;;
@@ -233,24 +245,58 @@
:fields filter-fields})]
(filter some? [header-section filter-section link-section])))
(defn- slack-dashboard-caption
"A plain mrkdwn caption (dashboard title, link, and filters) used as the PDF file's `initial_comment`, so the title
and the PDF arrive as a single Slack message rather than two."
[dashboard creator-name all-params top-level-params]
(str/join "\n"
(concat
[(format "*%s*" (:name dashboard))
(mkdwn-link-text (urls/dashboard-url (:id dashboard) all-params)
(format "Sent from %s by %s" (appearance/site-name) creator-name))]
(for [parameter top-level-params]
(parameter-markdown parameter)))))
(defn- dashboard-pdf
"Render the whole dashboard to a PDF for Slack, returning `{:bytes ... :filename ...}` or `nil` if rendering fails."
[dashboard creator-id parameters]
(try
(let [pdf-bytes (channel.render/render-dashboard-to-pdf (:id dashboard) creator-id (vec parameters))]
{:bytes pdf-bytes
:filename (-> dashboard
(some-> :name str/trim)
not-empty
(or "dashboard")
(str ".pdf"))})
(catch Throwable e
(log/error e "Error rendering dashboard subscription PDF for Slack; skipping PDF attachment")
nil)))
(mu/defmethod channel/render-notification [:channel/slack :notification/dashboard] :- [:sequential SlackMessage]
[_channel-type {:keys [payload creator]} {:keys [recipients]}]
[_channel-type {:keys [payload creator creator_id]} {:keys [recipients include_pdf]}]
(let [all-params (:parameters payload)
top-level-params (impl.util/remove-inline-parameters all-params (:dashboard_parts payload))
dashboard (:dashboard payload)
blocks (->> [(slack-dashboard-header dashboard (:common_name creator) all-params top-level-params)
;; Isolate each part: rendering one part (e.g. realizing its Hiccup into a PNG)
;; must not abort the whole subscription. On failure, substitute an error
;; placeholder block so the remaining cards still deliver (#74007).
(mapcat (fn [part]
(try
(part->sections! all-params part)
(catch Throwable e
(log/error e "Error rendering dashboard subscription part for Slack; substituting error placeholder")
[(text->markdown-section (str (tru "An error occurred while displaying this card.")))])))
(:dashboard_parts payload))]
flatten
(remove nil?))]
pdf (some-> (when include_pdf (dashboard-pdf dashboard creator_id all-params))
(assoc :comment (slack-dashboard-caption dashboard (:common_name creator)
all-params top-level-params)))
blocks (if pdf
;; PDF sends as a single file message (caption set in `send!`); skip the chart-image blocks.
[]
(->> [(slack-dashboard-header dashboard (:common_name creator) all-params top-level-params)
;; Isolate each part: rendering one part (e.g. realizing its Hiccup into a PNG)
;; must not abort the whole subscription. On failure, substitute an error
;; placeholder block so the remaining cards still deliver (#74007).
(mapcat (fn [part]
(try
(part->sections! all-params part)
(catch Throwable e
(log/error e "Error rendering dashboard subscription part for Slack; substituting error placeholder")
[(text->markdown-section (str (tru "An error occurred while displaying this card.")))])))
(:dashboard_parts payload))]
flatten
(remove nil?)))]
(for [channel-id (map notification-recipient->channel recipients)]
{:channel channel-id
:blocks blocks})))
(cond-> {:channel channel-id
:blocks blocks}
pdf (assoc :pdf pdf)))))

View File

@@ -3,6 +3,7 @@
[metabase.channel.render.card :as render.card]
[metabase.channel.render.image-bundle :as image-bundle]
[metabase.channel.render.js.svg :as js.svg]
[metabase.channel.render.pdf :as render.pdf]
[metabase.channel.render.preview :as render.preview]
[metabase.channel.render.style :as style]
[metabase.channel.render.table-data :as table-data]
@@ -34,6 +35,8 @@
render-adhoc-card-to-png
render-pulse-card-to-base64
png-from-render-info]
[render.pdf
render-dashboard-to-pdf]
[js.svg
icon]
[table-data

View File

@@ -174,7 +174,25 @@
"Height to render svg images. If not bound, will preserve aspect ratio of original image."
nil)
(def ^:dynamic ^:private *svg-background-color*
(def ^:dynamic *chart-size*
"When bound to a map `{:width <px> :height <px>}`, isomorphic (ECharts) charts rendered via
[[*javascript-visualization*]] use `:width`/`:height` as their intrinsic (logical) SVG
dimensions, and PNG rasterization targets those same dimensions (stretching to fit). Used to
make a backend chart fill an explicit pixel box -- e.g. a dashboard grid cell -- the way the
frontend does. When nil, keeps the legacy behavior (fixed width, aspect-preserving height).
An optional `:scale` (default 1) multiplies *only the raster* dimensions: the chart is still
laid out at `:width`x`:height` (so fonts, labels, and spacing are unchanged), but the SVG is
rasterized to `scale`x more pixels -- i.e. supersampling for a crisper image at the same on-page
size, without relaying the chart out smaller.
An optional `:fit-within?` (default false) tells legended charts to treat `:width`x`:height` as
the exact output box -- fitting the legend *inside* it rather than stacking it on top (which
returns an SVG taller than requested and makes it shrink to fit). Used by the PDF renderer so a
chart fills its grid cell's full width."
nil)
(def ^:dynamic *svg-background-color*
"Background color for rendered PNG images. Set to nil for transparent background.
Defaults to white to ensure charts are readable in dark mode email clients."
java.awt.Color/WHITE)
@@ -186,10 +204,15 @@
(let [^SVGOMDocument fixed-svg-doc (post-process svg-document fix-fill clear-style-node)
in (TranscoderInput. fixed-svg-doc)
out (TranscoderOutput. os)
transcoder (PNGTranscoder.)]
(.addTranscodingHint transcoder PNGTranscoder/KEY_WIDTH *svg-render-width*)
(when *svg-render-height*
(.addTranscodingHint transcoder PNGTranscoder/KEY_HEIGHT *svg-render-height*))
transcoder (PNGTranscoder.)
;; `:scale` (default 1) supersamples the raster only -- the SVG is laid out at the
;; logical :width/:height but rasterized to scale-times more pixels.
scale (:scale *chart-size* 1)
render-width (float (or (some-> (:width *chart-size*) (* scale)) *svg-render-width*))
render-height (some-> (or (some-> (:height *chart-size*) (* scale)) *svg-render-height*) float)]
(.addTranscodingHint transcoder PNGTranscoder/KEY_WIDTH render-width)
(when render-height
(.addTranscodingHint transcoder PNGTranscoder/KEY_HEIGHT render-height))
(when *svg-background-color*
(.addTranscodingHint transcoder PNGTranscoder/KEY_BACKGROUND_COLOR *svg-background-color*))
(.transcode transcoder in out))
@@ -222,10 +245,14 @@
(.asString (js.engine/execute-fn-name context "javascript_visualization"
(json/encode cards-with-data)
(json/encode dashcard-viz-settings)
(json/encode {:applicationColors (appearance/application-colors)
:startOfWeek (lib-be/start-of-week)
:customFormatting (appearance/custom-formatting)
:tokenFeatures (premium-features/token-features)}))))]
(json/encode (cond-> {:applicationColors (appearance/application-colors)
:startOfWeek (lib-be/start-of-week)
:customFormatting (appearance/custom-formatting)
:tokenFeatures (premium-features/token-features)}
*chart-size*
(assoc :width (:width *chart-size*)
:height (:height *chart-size*)
:fitWithinBounds (boolean (:fit-within? *chart-size*))))))))]
(-> response
json/decode+kw
(update :type (fnil keyword "unknown")))))

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,106 @@
(ns metabase.channel.render.pdf.common
"Shared constants for the backend dashboard->PDF renderer: page geometry, typography sizes, and
colours. Pulled into their own namespace so the font/typesetting/drawing namespaces can share them
without depending on the top-level renderer."
(:import
(java.awt Color)
(org.apache.pdfbox.pdmodel.common PDRectangle)))
(set! *warn-on-reflection* true)
;; --------------------------------------------------------------------------------------------
;; Page geometry
;; --------------------------------------------------------------------------------------------
(def margin "Page margin, in points." 36.0)
(def grid-cols "Columns in the dashboard layout grid." 24)
(def paper
"Paper specs. `:rows` is the number of square grid cells we lay down vertically -- A4's
printable height is ~35.31 units so we use 35 and accept the slack; Letter is exactly 32."
{:a4 {:rect PDRectangle/A4 :rows 35}
:letter {:rect PDRectangle/LETTER :rows 32}})
(def gutter-pt
"Points of empty space between adjacent cards. Realized as a half-gutter inset on all sides of a card's grid
rectangle, so two neighbours are separated by a full gutter while the outermost cards sit half a gutter inside
the page margin (and the first row sits half a gutter below the header).
Heading/text cells skip the *bottom* half of the inset so a one-row section header -- whose cell is barely
taller than its text -- still has room to render."
9.0)
(defn- centered
[container-size content-size]
(max 0.0 (/ (- container-size content-size)
2.0)))
(defn h-center
"Centers content horizontally in a container, returning the `x` coordinate for the content.
If the content overflows the container, returns the `x` of the container. In other words, this overflows the
right edge of the container rather than overflowing both sides.
Works with any unit, provided all values are in the same unit."
[base-x container-w content-w]
(+ base-x (centered container-w content-w)))
(defn v-center
"Centers content vertically in a container, returning the `y` coordinate for the content.
This is distinct from [[h-center]] because in PDFs y=0 is at the bottom of the page, increasing upwards.
If the content overflows the container, returns the `y` of the container. In other words, this overflows the
bottom edge of the container rather than overflowing both sides.
Works with any unit, provided all values are in the same unit."
[base-y container-h content-h]
(- base-y (centered container-h content-h)))
;; --------------------------------------------------------------------------------------------
;; Typography sizes
;; --------------------------------------------------------------------------------------------
(def dashboard-title-pt "Font size of the dashboard title." 20.0)
(def tab-title-pt "Font size of a tab title." 15.0)
(def heading-card-pt "Font size of a heading (section) card." 13.0)
(def text-card-pt "Base font size of a Markdown text card." 10.5)
(def chart-title-pt "Font size of a card's title above its chart." 11.0)
(def param-pt "Font size of parameter (filter) names and values." 9.5)
(def param-chip-gap "Horizontal gap between the name and value columns of the parameter table." 16.0)
(def line-height-factor "Line height as a multiple of the font size." 1.3)
(def header-pad-pt "Extra vertical padding added to a header line's height." 6.0)
(def ruby-scale
"Furigana (ruby) reading font size as a fraction of the base text size; the reading is drawn
centered above the base, e.g. {kanji|reading}."
0.55)
;; --------------------------------------------------------------------------------------------
;; Chart rasterization
;; --------------------------------------------------------------------------------------------
(def dpi
"Pixels per inch used to size rasterized charts. Higher = crisper but larger PDFs.
A grid cell's pixel dimensions are derived from its printed point size and this DPI. NOTE: this also sets the
chart's *logical* layout size, so raising it shrinks chart fonts/labels rather than just sharpening. Use
[[chart-supersample]] for pure sharpness."
150.0)
(def chart-supersample
"Supersampling factor for isomorphic (ECharts/visx) chart rasters: the chart is laid out at its `dpi`-derived
logical pixel size (so fonts/labels are unchanged) but the SVG is rasterized to this many times more pixels, then
drawn into the same on-page box -- crisper at the same page size.
1.0 = no supersampling; 2.0 makes a 150-DPI chart effectively 300 DPI (for print/zoom) at ~+12% PDF size.
See [[metabase.channel.render.js.svg/*chart-size*]]."
2.0)
;; --------------------------------------------------------------------------------------------
;; Colours
;; --------------------------------------------------------------------------------------------
(def ^Color body-color "Default body text colour (grey)." (Color. 0x6B 0x73 0x80))
(def ^Color link-color "Colour of hyperlink text (blue)." (Color. 0x1B 0x6F 0xC2))
(def ^Color code-color "Colour of inline code / code blocks (near-black)." (Color. 0x3B 0x3B 0x3B))

View File

@@ -0,0 +1,324 @@
(ns metabase.channel.render.pdf.font
"Low-level font handling for the backend dashboard->PDF renderer: the embedded-font registry, per-glyph
font resolution across scripts, string measurement, and the script utilities (RTL shaping/reordering,
CJK line-breaking).
Fonts are embedded TrueType (via `PDType0Font`) so we can render Unicode (Latin, Cyrillic, Greek, CJK,
Arabic, Hebrew, ...) rather than the ASCII-only Standard-14 fonts. `PDType0Font`s are tied to a specific
`PDDocument`, so [[load-fonts!]] loads a registry once per render and it is bound to [[*fonts*]]."
(:require
[better-cond.core :as b]
[clojure.java.io :as io]
[clojure.string :as str]
[medley.core :as m])
(:import
(com.ibm.icu.text ArabicShaping Bidi)
(org.apache.fontbox.ttf CmapLookup TrueTypeFont TTFParser)
(org.apache.pdfbox.io RandomAccessReadBuffer)
(org.apache.pdfbox.pdmodel PDDocument)
(org.apache.pdfbox.pdmodel.font PDFont PDType0Font)))
(set! *warn-on-reflection* true)
;; --------------------------------------------------------------------------------------------
;; Registry
;; --------------------------------------------------------------------------------------------
(def ^:private physical-font-resources
"Physical font keyword -> classpath resource. The fonts are TrueType (glyf) format, so PDFBox can subset them and
the output PDF only carries the glyphs actually used.
Lato regular and bold are preferred, as that's the Metabase UI's main body text, as well as the font for our logo and
other branding. Noto Sans has further coverage of Latin accents and special characters, plus Devanagari (Hindi et al),
Cyrillic and Greek. Hebrew and Arabic are handled by specific font faces, and finally the per-region Noto Sans
JP/KR/SC faces cover Japanese, Korean and Simplified Chinese.
See [[visual-order]] for RTL reordering for Hebrew and Arabic, and for *shaping* of Arabic."
{:noto-regular "fonts/pdf/NotoSans-Regular.ttf"
:noto-bold "fonts/pdf/NotoSans-Bold.ttf"
:noto-italic "fonts/pdf/NotoSans-Italic.ttf"
:noto-bold-italic "fonts/pdf/NotoSans-BoldItalic.ttf"
:noto-mono "fonts/pdf/NotoSansMono-Regular.ttf"
;; TODO: (bshepherdson 2026-06-11) We reuse the FE's bundled Lato asset directly rather than duplicating it into a
;; BE-owned path (it has full Latin/Cyrillic/Greek/Vietnamese coverage and is already shipped). This couples us to
;; the `frontend_client` asset layout. These files should be moved to a BE-owned part of `resources/` and the static
;; asset serving adjusted to preserve the FE URLs.
:brand-regular "frontend_client/app/fonts/Lato/Lato-Regular.ttf"
:brand-bold "frontend_client/app/fonts/Lato/lato-v16-latin-700.ttf"
:arabic-regular "fonts/pdf/NotoSansArabic-Regular.ttf"
:arabic-bold "fonts/pdf/NotoSansArabic-Bold.ttf"
:hebrew-regular "fonts/pdf/NotoSansHebrew-Regular.ttf"
:hebrew-bold "fonts/pdf/NotoSansHebrew-Bold.ttf"
:jp-regular "fonts/pdf/NotoSansJP-Regular.ttf"
:jp-bold "fonts/pdf/NotoSansJP-Bold.ttf"
:kr-regular "fonts/pdf/NotoSansKR-Regular.ttf"
:kr-bold "fonts/pdf/NotoSansKR-Bold.ttf"
:sc-regular "fonts/pdf/NotoSansSC-Regular.ttf"
:sc-bold "fonts/pdf/NotoSansSC-Bold.ttf"})
(def ^:dynamic *fonts*
"Per-document map of `{face-keyword {:fallbacks [<phys> ...]} ...}`, where each `<phys>` is
`{:font <PDType0Font> :cmap <CmapLookup>}`. Bound while a document is being rendered, because `PDType0Fonts` are
tied to a specific `PDDocument`; see [[load-fonts!]].
Latin keeps the Lato (or Noto Sans) look across all styles; the CJK regional faces are ordered fallbacks so mixed
English/Japanese text renders per-glyph from the right font.
CJK has no italic, so italic CJK falls back to the upright CJK face."
nil)
(defn- load-phys
"Reads a physical font from its `resource-path` within the classpath. Registers the font with the `PDDocument`."
[^PDDocument doc resource-path]
(let [bytes (with-open [is (io/input-stream (io/resource resource-path))]
(.readAllBytes is))
^TrueTypeFont ttf (.parse (TTFParser.) (RandomAccessReadBuffer. ^bytes bytes))]
{:font (PDType0Font/load doc ttf true)
:cmap (.getUnicodeCmapLookup ttf)}))
;; TODO: (bshepherdson 2026-06-11) It's wasteful of CPU time and memory to read these up front, when most of the
;; physical fonts won't be used at all. Instead, load them on demand and cache them for the life of the PDDocument.
(defn load-fonts!
"Load the font registry into `doc`. Call once per document; PDFBox subsets the embedded fonts at
save time, so only the glyphs actually used end up in the output PDF."
[^PDDocument doc]
(let [phys (update-vals physical-font-resources #(load-phys doc %))
;; `:id` is a stable keyword identifying the face -- used as a cheap cache key (see
;; [[*em-width*]]) instead of the heavy `{:fallbacks ...}` map.
face* (fn [id & fallbacks]
{:id id
:fallbacks (mapv phys (flatten fallbacks))})
regulars [:hebrew-regular :arabic-regular :jp-regular :kr-regular :sc-regular]
bolds [:hebrew-bold :arabic-bold :jp-bold :kr-bold :sc-bold]]
;; The fallback order for each style is important: first ordering by preference in case they overlap; and then
;; where there's no overlap (especially CJK) to put the heaviest fonts last.
{:regular (face* :regular :brand-regular :noto-regular regulars)
:bold (face* :bold :brand-bold :noto-bold bolds)
:italic (face* :italic :noto-italic regulars)
:bold-italic (face* :bold-italic :noto-bold-italic bolds)
:mono (face* :mono :noto-mono regulars)}))
(defn face
"The face map for face-keyword `k` (`:regular`, `:bold`, `:italic`, `:bold-italic`, `:mono`) in the
current document's [[*fonts*]] registry."
[k]
(get *fonts* k))
(defn- covers?
"Checks if a physical font can render a given codepoint `cp`."
[phys cp]
(pos? (.getGlyphId ^CmapLookup (:cmap phys) (int cp))))
;; --------------------------------------------------------------------------------------------
;; Script utilities -- RTL shaping/reordering and whitespace normalisation
;; --------------------------------------------------------------------------------------------
(defn normalize-ws
"Turn tabs, newlines and other control chars into spaces. PDFBox `showText` doesn't handle them."
^String [s]
(let [s (str s)
n (.length s)
sb (StringBuilder. n)]
(loop [i 0]
(when (< i n)
(let [cp (.codePointAt s i)]
(.appendCodePoint sb (if (< cp 32) (int \space) cp))
(recur (+ i (Character/charCount cp))))))
(.toString sb)))
(defn contains-rtl?
"True if `s` contains any character the bidi algorithm treats as right-to-left (e.g. Arabic, Hebrew).
This allows the LTR-only common case skip all shaping/reordering work."
[^String s]
(let [chars (.toCharArray s)]
(Bidi/requiresBidi chars 0 (alength chars))))
(defn base-rtl?
"True if the bidi base direction of `s` is right-to-left -- i.e. its first strong directional character is RTL
(Arabic/Hebrew). This is the per-paragraph direction used to right-align RTL titles, headings, and paragraphs.
A block has a single base direction shared by all its lines."
[^String s]
(let [s (str s)]
(and (contains-rtl? s)
(not (.baseIsLeftToRight (Bidi. s (int Bidi/LEVEL_DEFAULT_LTR)))))))
(def ^:private arabic-tashkeel
"Arabic combining marks: harakat/tanwin (fatha, kasra, damma, sukun, shadda, ...), the superscript alef, and
Quranic annotation signs.
These are zero-width marks that the font expects to anchor above/below their consonant via GPOS mark-to-base
positioning -- which PDFBox's `showText` doesn't perform.
Worse, ICU's shaper rewrites them into spacing presentation forms (U+FE70-block, with real advance widths) that then
render *after* the letter over empty space. We drop them before shaping, yielding clean unvocalised Arabic -- the
normal written form -- rather than mispositioned marks. Correctly stacking vowel marks would require a GPOS-aware
shaper such as HarfBuzz, which drags us into native library hell.
Apologies for the Unicode escapes here - these characters are mostly zero-width and are unreadable in code editors."
#"[\u0610-\u061A\u064B-\u065F\u0670\u06D6-\u06DC\u06DF-\u06E4\u06E7\u06E8\u06EA-\u06ED]")
(defn visual-order
"The heart of RTL support: if `s` contains right-to-left text (Arabic or Hebrew), return it shaped and reordered
for a left-to-right renderer; otherwise return it unchanged.
PDFBox's `showText` lays glyphs out left-to-right in the order given and does no OpenType shaping, so RTL text drawn
naively comes out reversed and (for Arabic) as disconnected isolated letters. We approximate proper rendering with
ICU4J, no native HarfBuzz needed:
- Arabic vowel marks (tashkeel) are stripped (see [[arabic-tashkeel]]) -- positioning them needs GPOS, which we can't
do; unvocalised Arabic is the normal written form anyway.
- `ArabicShaping` rewrites plain Arabic letters to their positional presentation forms -- initial, medial, final, and
isolated, plus the lam-alef ligature. The Noto Sans Arabic cmap covers that Presentation-Forms-B block, and the
result is properly joined Arabic.
- `Bidi` reorders the (shaped) logical string into visual order and mirrors brackets.
This runs per drawn line, not per paragraph, so it is a deliberate approximation rather than full bidi: a pure
Arabic/Hebrew line reads correctly, and mixed LTR/RTL is close enough for the short strings used for dashboard titles,
headings, etc."
^String [^String s]
(if-not (contains-rtl? s)
s
(let [s (str/replace s arabic-tashkeel "")
shaped (try (.shape (ArabicShaping. (int ArabicShaping/LETTERS_SHAPE)) s)
(catch Exception _ s))]
(.writeReordered (Bidi. ^String shaped (int Bidi/LEVEL_DEFAULT_LTR))
(int (bit-or Bidi/DO_MIRRORING Bidi/KEEP_BASE_COMBINING))))))
;; --------------------------------------------------------------------------------------------
;; Per-glyph font resolution
;; --------------------------------------------------------------------------------------------
(defn- cp-font
"Given a face and a codepoint, returns the physical font with the best matching glyph for the given codepoint.
Checks each of the `:fallbacks` in order. If none of the fonts has a glyph for this codepoint, returns nil."
[{:keys [fallbacks] :as _face} cp]
(m/find-first #(covers? % cp) fallbacks))
(defn font-runs
"Split `s` into maximal `[phys ^String chunk]` runs that all use the same physical font.
For each codepoint, the physical font chosen is the first one in this order that has the glyph:
- each of face's the `:fallbacks` in order
- abandon this codepoint, and fall back to ASCII `?`, and repeat the search.
Since every codepoint is resolved to some glyph, `showText` will never throw."
;; TODO: (bshepherdson, 2026-06-09) Consider using the "tofu" placeholder box ☐ instead.
[face ^String s]
(let [n (.length s)
emit (fn [out cur ^StringBuilder sb]
(cond-> out
(some? cur) (conj! [cur (.toString sb)])))]
(loop [i 0
cur nil
^StringBuilder sb nil
out (transient [])]
(b/cond
(>= i n) (persistent! (emit out cur sb))
:let [cp (.codePointAt s i)
phys (cp-font face cp)
;; If the codepoint doesn't have a matching glyph in any font, phys is nil. In that case, default `ch`
;; to `?` and search for `phys` again.
ch (if phys cp (int \?))
phys (or phys (cp-font face ch))]
;; At this point, `phys` is a physical font and `ch` is the codepoint to use within that font.
;; `cp` itself holds the proper input codepoint, which we still need to gets its `charCount` and advance the
;; right distance through the input string.
;; No change of font, so keep appending to the current StringBuilder.
(identical? phys cur) (do (.appendCodePoint sb ch)
(recur (+ i (Character/charCount cp)) cur sb out))
;; Hit a seam between fonts, so append the current run to `out` and start a new one.
:else (recur (+ i (Character/charCount cp))
phys
(doto (StringBuilder.) (.appendCodePoint ch))
(emit out cur sb))))))
;; --------------------------------------------------------------------------------------------
;; Measurement
;; --------------------------------------------------------------------------------------------
(defn ^{:clojure.core.memoize/args-fn (fn [face ^String s]
[(:id face) s])}
raw-em-width-inner
"Don't call this; use the memoized version [[*em-width*]]."
^double [face ^String s]
(transduce (map (fn [[phys ^String chunk]]
(/ (.getStringWidth ^PDFont (:font phys) chunk) 1000.0)))
+ 0.0
(font-runs face (visual-order (normalize-ws s)))))
(def ^{:dynamic true
:arglists '([face ^String s])}
*em-width*
"Compute the width of `s` in `face` measured in *ems* (1.0 = the font size), so it can be multiplied by any font
size to get points.
Measures the *shaped* form (see [[visual-order]]) exactly as `draw-line!` draws it, so scripts (e.g. Arabic) where
isolated letters are wider than their joined-up presentation forms are measured on the presentation form that will
actually be rendered. A naive sum over isolated letter widths would overstate the space needed, leaving big gaps
after each word in such scripts.
Memoized within the context of each render, keyed on `[(:id face) s]`. Since ems are independent of font size, this
can be reused for whatever font size. We compute the size of each string repeatedly, so this saves a bunch of
recomputation. Bound in `render-dashboard-to-pdf` to the memoized version; defaults when unbound to the uncached
inner function."
raw-em-width-inner)
(defn text-width
"Width in points of `s` drawn with `face` at `font-pt`. The per-glyph measurement lives in (cached) [[*em-width*]];
this just scales it by the font size."
^double [face font-pt ^String s]
(* (double font-pt) (*em-width* face s)))
;; --------------------------------------------------------------------------------------------
;; CJK line-breaking (kinsoku)
;; --------------------------------------------------------------------------------------------
(defn cjk-char?
"Codepoints from scripts that don't use spaces between words. Line breaks are allowed between adjacent characters from
these scripts. (CJK ideographs, kana, hangul, and CJK punctuation/fullwidth forms)."
[cp]
(or
;; 2E80-2FFF CJK radicals, Kangxi
;; 3000-303F CJK symbols and punctuation
;; 3040-30FF hiragana + katakana
(<= 0x2E80 cp 0x30FF)
(<= 0x3400 cp 0x4DBF) ; CJK ext A
(<= 0x4E00 cp 0x9FFF) ; CJK unified ideographs
(<= 0xAC00 cp 0xD7A3) ; hangul syllables
(<= 0xF900 cp 0xFAFF) ; CJK compat ideographs
(<= 0xFF00 cp 0xFFEF))) ; halfwidth & fullwidth forms
(def ^:private no-break-before-chars
"CJK characters that must not start a line (closing punctuation, small kana); they attach to the previous break unit.
This is a simplified kinsoku rule.
Spelled out as `\\uXXXX` escapes rather than literal glyphs so the source stays ASCII -- several of these
forms trip the whitespace linter. Each line names the characters it encodes."
(set (str ; punctuation: ideographic & fullwidth comma/full-stop, katakana middle dot
"\u3001\u3002\uFF0C\uFF0E\u30FB"
; fullwidth colon, semicolon, exclamation, question mark
"\uFF1A\uFF1B\uFF01\uFF1F"
; fullwidth right paren, square & curly brackets
"\uFF09\uFF3D\uFF5D"
; right brackets: corner, white-corner, lenticular, tortoise-shell, angle, double-angle
"\u300D\u300F\u3011\u3015\u3009\u300B"
; wave dash, prolonged sound mark, horizontal ellipsis
"\u301C\u30FC\u2026"
; small hiragana: a i u e o tu ya yu yo wa
"\u3041\u3043\u3045\u3047\u3049\u3063\u3083\u3085\u3087\u308E"
; small katakana: a i u e o tu ya yu yo wa
"\u30A1\u30A3\u30A5\u30A7\u30A9\u30C3\u30E3\u30E5\u30E7\u30EE")))
(defn no-break-before?
"True if `cp` is a CJK character that must not start a line (closing punctuation, small kana), so
it sticks to the previous break-unit. A simplified kinsoku rule (see [[no-break-before-chars]])."
[cp]
(and (<= (int cp) 0xFFFF)
(contains? no-break-before-chars (char cp))))

View File

@@ -0,0 +1,217 @@
(ns metabase.channel.render.pdf.markdown
"Markdown parser (powered by Flexmark) producing a stream of styled runs of text, suitable for the PDF rendering
engine to consume."
(:require
[clojure.string :as str]
[metabase.util.http :as u.http])
(:import
(com.vladsch.flexmark.ast AutoLink BlockQuote BulletList Code Emphasis FencedCodeBlock
HardLineBreak Heading Image IndentedCodeBlock Link MailLink OrderedList
Paragraph SoftLineBreak StrongEmphasis Text ThematicBreak)
(com.vladsch.flexmark.ext.autolink AutolinkExtension)
(com.vladsch.flexmark.parser Parser)
(com.vladsch.flexmark.util.ast Node)
(com.vladsch.flexmark.util.data MutableDataSet)
(java.awt.image BufferedImage)
(java.io ByteArrayInputStream)
(javax.imageio ImageIO ImageReader)
(org.apache.pdfbox.pdmodel PDDocument)
(org.apache.pdfbox.pdmodel.graphics.image LosslessFactory)))
(set! *warn-on-reflection* true)
;; --------------------------------------------------------------------------------------------
;; Markdown image fetching. Markdown text cards can reference remote images, which means fetching
;; user-provided URLs server-side -- the classic SSRF risk. The SSRF-hardened fetch itself lives in
;; [[metabase.util.http/fetch-bytes]]; here we only add the image-specific decoding: restrict to
;; raster content-types and reject over-large images (decompression-bomb guard) before decoding.
;; Any failure returns nil and the caller renders a Markdown link instead.
;; --------------------------------------------------------------------------------------------
(def ^:private image-max-megapixels 24)
(def ^:private allowed-image-content-types #{"image/png" "image/jpeg" "image/gif"})
(defn- decode-image
"Decode `bytes` into a `PDImageXObject` embedded in `doc`, rejecting images over the megapixel cap
(decompression-bomb guard) by reading dimensions before the full decode. Returns nil on failure."
[^PDDocument doc ^bytes bytes]
(try
(with-open [iis (ImageIO/createImageInputStream (ByteArrayInputStream. bytes))]
(let [readers (ImageIO/getImageReaders iis)]
(when (.hasNext readers)
(let [^ImageReader rdr (.next readers)]
(try
(.setInput rdr iis)
(let [w (.getWidth rdr 0), h (.getHeight rdr 0)]
(when (and (pos? w) (pos? h)
(<= (* (long w) (long h)) (* image-max-megapixels 1000000)))
(let [^BufferedImage bi (.read rdr 0)]
(LosslessFactory/createFromImage doc bi))))
(finally (.dispose rdr)))))))
(catch Throwable _ nil)))
(defn fetch-image!
"SSRF-safely fetch + decode a remote image URL into a `PDImageXObject`, or nil if anything fails."
[^PDDocument doc url]
(when-let [bytes (:bytes (u.http/fetch-bytes url {:allowed-content-types allowed-image-content-types}))]
(decode-image doc bytes)))
;; --------------------------------------------------------------------------------------------
;; Markdown -> styled runs (text cards). We parse with flexmark (the same library the email
;; pipeline uses) but walk the AST ourselves, emitting block + inline-run structure that maps to
;; PDFBox fonts/colors -- since PDFBox has no HTML engine, the email's HTML output is no use here.
;; --------------------------------------------------------------------------------------------
(def ^:private md-parser
(delay (-> (MutableDataSet.)
(.set Parser/EXTENSIONS [(AutolinkExtension/create)])
(Parser/builder)
(.build))))
(defn- node-children [^Node node]
;; getChildren returns an Iterable, which `seq` supports out of the box.
(seq (.getChildren node)))
(defn- md-unescape
"Resolve CommonMark backslash escapes (`\\,` -> `,`) the way flexmark's HtmlRenderer does -- `.getChars` on a Text
node returns the raw source, escapes included. Parameter substitution escapes interpolated values, so e.g. a
multi-value filter arrives as `A\\, B\\, and C`."
[s]
(str/replace (str s) #"\\(\p{Punct})" "$1"))
(declare ^:private inline-runs)
(defn- parse-ruby
"Split plain text into runs, turning `{base|reading}` furigana shorthand into ruby runs
(`{:ruby? true :base … :reading …}`) and leaving the rest as ordinary text runs."
[text style href]
(let [s (str text)
m (re-matcher #"\{([^{}|]+)\|([^{}|]+)\}" s)]
(loop [last 0
out []]
(if (.find m)
(recur (.end m)
(cond-> out
(< last (.start m)) (conj (assoc style :text (subs s last (.start m)) :href href))
true (conj (assoc style :ruby? true :base (.group m 1)
:reading (.group m 2) :href href))))
(cond-> out
(< last (count s)) (conj (assoc style :text (subs s last) :href href)))))))
(defn- inline-node->runs
"Convert a single inline node into styled runs. Images nested in inline content degrade to their alt text; top-level
paragraph images are pulled out as `:image` blocks (see `paragraph->blocks`)."
[^Node c style href]
(condp instance? c
Text (-> (.getChars c) md-unescape (parse-ruby style href))
StrongEmphasis (inline-runs c (assoc style :bold? true) href)
Emphasis (inline-runs c (assoc style :italic? true) href)
Link (inline-runs c style (str (.getUrl ^Link c)))
Code [(assoc style
:code? true
:text (str (.getText ^Code c))
:href href)]
AutoLink (let [u (str (.getText ^AutoLink c))]
[(assoc style :text u :href u)])
MailLink (let [u (str (.getText ^MailLink c))]
[(assoc style :text u :href (str "mailto:" u))])
Image (let [alt (str (.getText ^Image c))]
[(assoc style
:text (if (str/blank? alt) "[image]" alt)
:href (str (.getUrl ^Image c)))])
SoftLineBreak [{:text " " :space? true}]
HardLineBreak [{:break? true}]
(if (.getFirstChild c)
(inline-runs c style href)
(-> (.getChars c) md-unescape (parse-ruby style href)))))
(defn- inline-runs
"Flatten a block node's inline children into styled runs: each is `{:text :bold? :italic? :code? :href}`, plus
`{:break? true}` for a hard line break."
([^Node node] (inline-runs node {} nil))
([^Node node style href]
(into [] (mapcat #(inline-node->runs % style href))
(node-children node))))
;; FIXME: There are so many `loop`s in all this code. Either we should suck it up and use mapcat and flatten and other
;; Clojure niceties without worrying about performance, *or* these should be written as reducibles that stream their
;; results into a sink, rather than all this conditional `conj` onto `out` etc.
(defn- paragraph->blocks
"A paragraph normally becomes one `:paragraph` block, but top-level images are pulled out as standalone `:image`
blocks, interleaved with the surrounding text."
[^Node node]
(loop [children (node-children node)
runs []
out []]
(if (empty? children)
(cond-> out (seq runs) (conj {:kind :paragraph :runs runs}))
(let [^Node c (first children)]
(if (instance? Image c)
(recur (rest children) []
(cond-> out
(seq runs) (conj {:kind :paragraph :runs runs})
true (conj {:kind :image
:src (str (.getUrl ^Image c))
:alt (str (.getText ^Image c))})))
(recur (rest children) (into runs (inline-node->runs c {} nil)) out))))))
(declare ^:private block->blocks)
;; FIXME: Use this for a few of the other "first one only" things like this. There's at least two others IIRC.
(defn- on-first
"Returns a stateful transducer that applies `(f x)` only to the first element in the sequence. Passes through all
subsequent elements unchanged."
[f]
(fn [xf]
(let [seen? (volatile! false)]
(fn
([] (xf))
([res] (xf res))
([res x]
(xf res (if @seen?
x
(do (vreset! seen? true)
(f x)))))))))
(defn- list->blocks [^Node list-node depth ordered?]
(into [] (comp (map-indexed
(fn [idx ^Node item]
(into [] (comp (mapcat #(block->blocks % (inc depth)))
(on-first #(cond-> %
(= :paragraph (:kind %)) (assoc :kind :list-item
:indent depth
:marker (if ordered?
(str (inc idx) ". ")
"- ")))))
(node-children item))))
cat)
(node-children list-node)))
(defn- block->blocks
"Convert a flexmark block node into a flat vector of layout blocks `{:kind :runs ...}`."
[^Node node depth]
(condp instance? node
Heading [{:kind :heading :level (.getLevel ^Heading node) :runs (inline-runs node)}]
Paragraph (paragraph->blocks node)
BulletList (list->blocks node depth false)
OrderedList (list->blocks node depth true)
FencedCodeBlock [{:kind :code-block :text (str (.getContentChars ^FencedCodeBlock node))}]
IndentedCodeBlock [{:kind :code-block :text (str (.getContentChars ^IndentedCodeBlock node))}]
BlockQuote (into [] (comp (mapcat #(block->blocks % depth))
(map #(update % :indent (fnil inc 0))))
(node-children node))
ThematicBreak [{:kind :hr}]
(if (.getFirstChild node)
(into [] (mapcat #(block->blocks % depth))
(node-children node))
[])))
(defn parse-markdown-blocks
"Parses a string of Markdown syntax into a stream of blocks containing `:runs` of text."
[text]
(->> (.parse ^Parser @md-parser (str text))
node-children
(into [] (mapcat #(block->blocks % 0)))))
;; FIXME: Big picture, all the Markdown code is repeatedly pouring nested stuff into a top-level, flattened list.
;; It seems like it would be much more efficient, and probably easier to read too, if the whole thing were written
;; as a reducible pipeline, streaming the results into a final sink.

View File

@@ -0,0 +1,538 @@
(ns metabase.channel.render.pdf.typeset
"Pure typesetting for the backend dashboard->PDF renderer: turning styled runs into measured, wrapped lines and
blocks. Nothing here draws to a PDFBox content stream -- everything returns data (word / line / item maps and
heights) that `metabase.channel.render.pdf` then draws.
The pipeline is runs -> words ([[runs->words]]) -> measured items ([[->measured-item]]) -> wrapped lines
([[words->lines]], via the greedy [[pack-units->lines]]), with RTL word reordering ([[reorder-bidi-items]]) and
block/line height measurement ([[block-height]], [[fit-scale]])."
(:require
[better-cond.core :as b]
[clojure.string :as str]
[metabase.channel.render.pdf.common :as common]
[metabase.channel.render.pdf.font :as font])
(:import
(com.ibm.icu.text Bidi)
(java.awt Color)))
(set! *warn-on-reflection* true)
;; --------------------------------------------------------------------------------------------
;; Greedy streaming grouping (`segment`)
;;
;; A stateful transducer for greedily "packing" input items into grouped output items; a
;; generalized `partition-by`. Think packing words on a line of text, dashcards into pages, etc.
;;
;; User code (*policy*) is controlled by two (three) functions: opening a new group, including or
;; rejecting a new item in that group, and optionally finalizing each group before it is emitted
;; downstream.
;; --------------------------------------------------------------------------------------------
(defn segment
"A stateful transducer that greedily groups a stream of items. `collect` is the *policy*, given as two arities,
and this transducer is the *mechanism* that drives them:
- `(collect item)` is **open**: start a new group seeded with `item`. Must always succeed, so a group always holds
at least one item. This guarantees progress - if the item passed to **open** is too big even for a fresh group
(e.g. a word too long for a line all by itself) then **open** should forcibly split it.
- `(collect acc item)` is **add**: fold `item` into the open group `acc`. Returns one of three things:
- An updated `acc`: accepts the new item
- `::reject`: the new `item` cannot fit! The current group is closed, and `item` is passed to **open** to become
the first item in a new group.
- `::break`: `item` represents the end of a group (e.g. a hard line break) and therefore the current group should
be closed (if any) and `item` *dropped*. The next input item (if any) is used to **open** the next group.
`close` (default `identity`) realizes a finished accumulator into the emitted value -- e.g. trim and RTL-reorder
a packed line, or `str` a `StringBuilder`.
Honors downstream `reduced?` (early termination) and flushes any open group in the transducer's completion step.
See the tests for reimplementations of [[partition-all]] and [[partition-by]] using `segment`."
([collect] (segment collect identity))
([collect close]
(fn [rf]
(let [state (volatile! ::empty)]
(fn
([] (rf))
([result]
(let [acc @state]
(vreset! state ::empty)
(rf (if (= acc ::empty)
result
(unreduced (rf result (close acc)))))))
([result item]
(let [acc @state]
(if (= acc ::empty)
(do (vreset! state (collect item))
result)
(let [r (collect acc item)]
(case r
::reject (let [result (rf result (close acc))]
;; closed; reopen with `item` -- unless downstream is done, then stop cleanly
(vreset! state (if (reduced? result)
::empty
(collect item)))
result)
::break (do (vreset! state ::empty)
(rf result (close acc)))
(do (vreset! state r)
result)))))))))))
(defn- text->codepoint-items
"`text` as a seq of `{:cp <codepoint>}` items, one per codepoint (surrogate pairs kept intact) --
the input stream to the [[runs->words]] pipeline. `.toArray` materialises the codepoint
`IntStream` into an `int[]` (a `java.util.stream.IntStream` isn't directly reducible)."
[^String text]
(into [] (map (fn [cp] {:cp cp}))
(.toArray (.codePoints text))))
(defn- run-style
"The style map carried on each piece of a run -- everything but the structural keys, plus `:link?`
when the run is a hyperlink (so [[run-color]] can colour it)."
[r]
(cond-> (dissoc r :text :base :reading :ruby? :space? :break?)
(:href r) (assoc :link? true)))
(defn- run->items
"Expand one run into the [[runs->words]] input stream: a `:break?`/`:ruby?` run becomes a single atomic item;
a text run becomes one `{:cp _ :style _}` item per codepoint, each tagged with the run's style so a
[[collect-word]] word can carry pieces from more than one run."
[r]
(cond
(:break? r) [{:break? true}]
(:ruby? r) [{:ruby? true :base (:base r) :reading (:reading r) :style (run-style r)}]
:else (let [style (run-style r)]
(map #(assoc % :style style) (text->codepoint-items (str (:text r "")))))))
(defn- mark-space-before
"Stateful transducer over run items: drops whitespace codepoint items and stamps every surviving item with
`:space-before?` -- true iff whitespace immediately preceded it.
Accepts an initial `:space-before?` value, since some contexts start with leading space.
A `:break?` clears `:space-before?` - it represents a hard line break or similar, and the next unit is therefore
flush with the start."
[init-space-before?]
(fn [rf]
(let [space-before? (volatile! (boolean init-space-before?))]
(fn
([] (rf))
([result] (rf result))
([result item]
(cond
(and (:cp item) (Character/isWhitespace (int (:cp item))))
(do (vreset! space-before? true) result)
(:break? item)
(do (vreset! space-before? false) (rf result item))
:else
(let [sb @space-before?]
(vreset! space-before? false)
(rf result (assoc item :space-before? sb)))))))))
(defn- collect-word
"[[segment]] policy for grouping the run-item stream into break-units (words) that can't break across a line. This
decides *word* boundaries only: the boundaries are whitespace and CJK char boundaries, NOT style changes, so
`\"**bo**ld\"` stays one indivisible word. Each CJK character is its own word, except for a few no-break-before
characters ([[font/no-break-before?]]) which stick onto the end of the open word (kinsoku). `:break?`/`:ruby?` items are
atomic words. The accumulator just collects the word's raw codepoint items in `:cps` -- splitting them into styled
`:pieces` is [[close-word]]'s job. `:cjk-unit?` means the word can no longer take an ordinary character."
([item]
(if-let [cp (:cp item)]
{:space-before? (boolean (:space-before? item))
:cps [item]
:cjk-unit? (font/cjk-char? (int cp))}
{:atomic item}))
([acc item]
(cond
(:atomic acc) ::reject
(not (:cp item)) ::reject ; a ruby/break can't join a word
:else
(let [cp (int (:cp item))]
(cond
;; a no-break-before char glues onto the open word even across a space (kinsoku wins)
(font/no-break-before? cp) (-> acc (assoc :cjk-unit? true) (update :cps conj item))
(:space-before? item) ::reject ; whitespace preceded -> new word
(font/cjk-char? cp) ::reject ; each CJK char is its own word
(:cjk-unit? acc) ::reject
:else (update acc :cps conj item))))))
(defn- codepoints->string
^String [items]
(let [sb (StringBuilder.)]
(doseq [{:keys [cp]} items] (.appendCodePoint sb (int cp)))
(.toString sb)))
(defn- close-word
"Realise a [[collect-word]] accumulator into a word token: an atomic `:break?`/`:ruby?` item passes
through; a char word splits its codepoints into same-style `:pieces` (`partition-by :style` -- the
one place piece boundaries are decided), each `{:style _ :text _}`."
[acc]
(if-let [item (:atomic acc)]
item
{:space-before? (:space-before? acc)
:pieces (mapv (fn [grp] {:style (:style (first grp)) :text (codepoints->string grp)})
(partition-by :style (:cps acc)))}))
(defn- item-codepoint-count
"Number of codepoints across all of a measured word item's `:pieces`."
^long [item]
(transduce (map (fn [p] (let [^String t (:text p)] (.codePointCount t 0 (.length t)))))
+ 0 (:pieces item)))
(defn- split-into-chars
"Split an over-wide measured word into one measured item per codepoint (each a single-piece word
carrying its source piece's font/colour), so the wrapper can break inside it. The first keeps the
word's `:space-before?`. Used to hard-break a token wider than the whole line."
[{:keys [pieces sp space-before?] :as _item}]
(->> (for [{:keys [font pt] :as piece} pieces
cp (seq (.codePoints ^String (:text piece)))
:let [text (String. (Character/toChars (int cp)))]]
(merge (select-keys piece [:font :pt :color :href])
{:text text
:ww (font/text-width font pt text)}))
(map-indexed (fn [i p]
{:space-before? (and space-before? (zero? i))
:sp sp
:pieces [p]
:ww (:ww p)}))))
(defn split-word-into-chars
"The `split-fn` for [[pack-units->lines]]: break a too-wide word into per-codepoint pieces, leaving ruby groups
and single-codepoint words atomic."
[item]
(if (or (:ruby? item)
(<= (item-codepoint-count item) 1))
[item]
(split-into-chars item)))
(defn- pack-units->lines
"Greedily pack pre-measured `units` into lines no wider than `max-w`. This is the shared word-wrap algorithm behind
[[words->lines]], through which all text (Markdown cards, plain titles, headings, parameters) is wrapped.
Each unit is a map carrying `:ww` (its drawn width), `:sp` (the width of one leading space), and `:space-before?`
(whether the source had whitespace before it). A unit may instead be a `{:break? true}` marker, which forces a line
break. A unit that alone exceeds `max-w` is broken via `split-fn` (a unit -> a seq of narrower pre-measured units;
it returns the unit unchanged when it can't be split further, e.g. a single glyph).
Returns a seq of lines, where each line is a seq of the same opaque input units, with each unit's `:space-before?`
updated to whether *this line* draws a space before it. It's always false at the start of a line, so callers don't
have to special-case the first unit.
Note that while this resembles the shape that [[segment]] is for, there are two problems. First, [[segment]] is
about grouping an input stream into *runs* with some property (e.g. text style) all the same; this function is
about greedy bin-packing. Critically, atoms in this function are not *quite* indivisible! A single atom that is too
big for a line by itself must be *divided* by [[split-word-into-chars]] to force a line break. There's no way to
include that in the policy for [[segment]] without making it so intricate as to remove its value. An alternative
HOF or transducer for bin-packing could be written, but this would be the only caller."
[units max-w split-fn]
(loop [[{:keys [break? sp space-before? ww] :as u} :as units] units
line []
line-w 0.0
lines (transient [])]
(b/cond
(empty? units) (cond-> lines
(seq line) (conj! line)
:always persistent!)
;; a hard break flushes the current line (which may be empty -> a blank line)
break? (recur (rest units) [] 0.0 (conj! lines line))
:let [add-space? (and (seq line) space-before?)
advance (+ ww (if add-space? sp 0.0))
;; a unit alone on its line and wider than the whole line is a split candidate
parts (when (and (empty? line)
(> ww max-w))
(split-fn u))]
;; the candidate actually split into more than one piece: retry with those in front
(next parts) (recur (concat parts (rest units))
line line-w lines)
;; the line is nonempty and the next unit won't fit: break here and retry the unit
(and (seq line)
(> (+ line-w advance) max-w)) (recur units [] 0.0 (conj! lines line))
:else (recur (rest units)
(conj line (assoc u :space-before? (boolean add-space?)))
(+ line-w advance)
lines))))
(defn- run-font [{:keys [bold? italic? code?]}]
(font/face (cond
code? :mono
(and bold? italic?) :bold-italic
bold? :bold
italic? :italic
:else :regular)))
(defn heading-pt
"Font size for a Markdown heading of the given `level` (1 = `#`, 2 = `##`, ...)."
[level]
(case (long level)
1 15.0
2 13.5
3 12.0
11.5))
;; All text -- Markdown cards and plain titles/headings alike -- funnels through `runs->words` ->
;; `->measured-item` -> `pack-units->lines` -> `draw-item-lines!`. Plain text (see `draw-text-block!`)
;; is just the degenerate case of a single styled run.
(defn runs->words
"Turn a run stream (style + text chunks, possibly spanning whole paragraphs) into a flat vector of
word tokens that can't break across a line: a multi-piece word `{:space-before? _ :pieces
[{:style _ :text _}…]}`, a `{:ruby? …}` furigana group, or a `{:break? true}` hard break.
One composed transducer over all runs at once: flatten to a codepoint/atomic stream
([[run->items]]), drop whitespace + stamp `:space-before?` ([[mark-space-before]]), then greedily
group into words ([[segment]] + [[collect-word]]). Because words split on *whitespace* rather than
run boundaries, a word naturally spans runs (so `\"**bo**ld\"` is one indivisible word with two
styled pieces), which also makes inter-run spacing and CJK kinsoku fall out with no bookkeeping."
[runs]
(into [] (comp (mapcat run->items)
(mark-space-before false)
(segment collect-word close-word))
runs))
(defn- run-color [style]
(or (:color style) ; an explicit colour overrides (e.g. gray parameter values)
(cond (:link? style) common/link-color
(:code? style) common/code-color
:else Color/BLACK)))
(defmulti ->measured-item
"Turn one Markdown word token into a drawable, pre-measured item (resolving its font and colour),
ready for [[pack-units->lines]]. A furigana group becomes an atomic item whose width is the wider
of its base and reading; a `{:break? true}` marker passes through unchanged. `:space-before?` is
left as the source-level flag here -- `pack-units->lines` finalises it per line."
{:arglists '([word-token base-pt heading?])}
(fn [w _base-pt _heading?]
(cond
(:break? w) :break
(:ruby? w) :ruby
:else :default)))
(defmethod ->measured-item :break [w _base-pt _heading?]
w)
(defmethod ->measured-item :ruby [{:keys [base reading style] :as w} base-pt heading?]
;; furigana group: base text with a smaller reading centered above; the item is atomic
;; (the reading must never wrap away from its base), and as wide as the wider of the two.
(let [font (run-font (cond-> style heading? (assoc :bold? true)))
ruby-pt (* base-pt common/ruby-scale)
bw (font/text-width font base-pt base)
rw (font/text-width font ruby-pt reading)]
(merge (select-keys w [:base :reading :space-before?])
{:ruby? true
:font font
:pt base-pt
:ruby-pt ruby-pt
:base-ww bw
:reading-ww rw
:color (run-color style)
:href (:href style)
:ww (max bw rw)
:sp (font/text-width font base-pt " ")})))
(defmethod ->measured-item :default [{:keys [pieces space-before?]} base-pt heading?]
;; a word is a sequence of same-style pieces; each piece resolves its own font/colour and the word's
;; width is their sum. The line packer treats the word atomically; the draw path lays the pieces out
;; contiguously (no inter-piece space).
(let [measured (mapv (fn [{:keys [style text]}]
(let [font (run-font (cond-> style heading? (assoc :bold? true)))]
{:font font :pt base-pt :color (run-color style) :href (:href style)
:text text :ww (font/text-width font base-pt text)}))
pieces)]
{:space-before? space-before?
:pieces measured
:ww (transduce (map :ww) + 0.0 measured)
;; one leading space, drawn in the first piece's font when this word follows another on a line
:sp (let [{:keys [font]} (first measured)] (font/text-width font base-pt " "))}))
(defn words->lines
"Greedily wrap `words` to `max-w`, resolving each word's font/colour. Returns a vector of lines,
each a vector of drawable items `{:pieces :ww :sp :space-before? ...}` (or atomic ruby/break items)."
[words base-pt heading? max-w]
(pack-units->lines (map #(->measured-item % base-pt heading?) words) max-w split-word-into-chars))
(defn line-extra
"Extra vertical space a line needs above its base text -- the furigana band, if it has any ruby."
[line base-pt]
(if (some :ruby? line) (* base-pt common/ruby-scale 1.2) 0.0))
(defn lines-height
"Total vertical points a sequence of wrapped item-lines consumes at `base-pt`, including any
furigana bands (see [[line-extra]])."
[lines base-pt]
(let [lh (* base-pt common/line-height-factor)]
(reduce (fn [acc line] (+ acc lh (line-extra line base-pt))) 0.0 lines)))
(defmulti ^:private block-height
"Vertical points a block consumes when laid out at `scale` (mirrors `draw-block!`)."
{:arglists '([block cell-w scale])}
(fn [block _cell-w _scale]
(:kind block)))
(defmethod block-height :hr [_block _cell-w scale]
(* scale 10.0))
(defmethod block-height :image [_block _cell-w _scale]
;; Images don't participate in the font fit-scale: shrinking the text wouldn't shrink an image,
;; so an image must never force the surrounding text smaller. They contribute no height here and
;; are instead scaled (aspect-preserved) to fit whatever space is left after the text -- see
;; `draw-image-block!`.
0.0)
(defmethod block-height :code-block [{:keys [text]} _cell-w scale]
(-> (str text)
str/split-lines
count
(* 9.0 scale common/line-height-factor)
(+ 2.0)))
(defmethod block-height :default
[{:keys [indent kind level marker runs]} cell-w scale]
(let [heading? (= :heading kind)
base-pt (* (if heading?
(heading-pt level)
common/text-card-pt)
scale)
marker-w (if marker
(font/text-width (font/face :regular) base-pt marker)
0.0)
content-w (- cell-w
(* 14.0 (long (or indent 0)))
marker-w)]
(-> (runs->words runs)
(words->lines base-pt heading? content-w)
(lines-height base-pt))))
(defn- markdown-total-height [blocks cell-w scale]
(transduce (map #(+ (block-height % cell-w scale) (* 4.0 scale)))
+ 0.0
blocks))
(defn fit-scale
"Largest font scale (<= 1.0, down to a readability floor) at which the markdown fits `cell-h`.
Shrinks the text only when the content would otherwise overflow (and clip) the cell."
[blocks cell-w cell-h]
(or (first (filter #(<= (markdown-total-height blocks cell-w %)
cell-h)
(range 1.0 0.44 -0.05)))
0.45))
(defn item-text
"The full text of a measured item -- its `:text`, a ruby item's `:base`, or a multi-piece word's
concatenated piece text. Used for RTL detection and bidi reordering."
[{:keys [base text pieces] :as _item}]
(cond
text (str text)
base (str base)
pieces (transduce (map :text) str pieces) ; a multi-piece word's full text
:else ""))
(defn- any-rtl? [items]
(boolean (some #(font/contains-rtl? (item-text %))
items)))
(defn reorder-bidi-items
"Reorder a wrapped markdown line's word items from logical to visual order, so a right-to-left paragraph reads
right-to-left at the word level. Each item's own glyphs are shaped/reversed separately by `draw-line!`
(via `font/visual-order`); this handles the *order of the words*.
We resolve bidi levels with ICU over the line's logical text (one representative offset per item), apply rule L2
via `Bidi/reorderVisual`, then recompute each item's `:space-before?` for its new neighbour so inter-word spacing
follows the visual order. Lines with no RTL text (the common case) are returned untouched."
[items]
(if (or (empty? items)
(not (any-rtl? items)))
items
(let [sb (StringBuilder.)
starts (mapv (fn [it]
(when (and (:space-before? it)
(pos? (.length sb)))
(.append sb \space))
(let [start (.length sb)]
(.append sb (item-text it))
start))
items)
bidi (Bidi. (.toString sb) (int Bidi/LEVEL_DEFAULT_LTR))
levels (byte-array (map (fn [start] (.getLevelAt bidi (int start)))
starts))
order (Bidi/reorderVisual levels)]
(into [] (map-indexed
(fn [vp lp]
;; visually adjacent items are logically adjacent; the gap between two words is
;; recorded on the one with the higher logical index, so reuse that flag.
(let [sep? (and (pos? vp)
(->> (max (aget order (dec vp))
(int lp))
(nth items)
:space-before?
boolean))]
(assoc (nth items lp) :space-before? sep?)))
order)))))
(defn md-line-width
"Drawn width of a (reordered) markdown line: each item's advance plus the space before it."
^double [items]
(transduce (map (fn ^double [{:keys [sp space-before? ww]}]
(double (+ ww (if space-before? sp 0.0)))))
+ 0.0 items))
(def face-id->style
"Inverse of [[run-font]]: the run style that selects each face. Lets plain text drawn with an explicit face be
expressed as a single styled run and fed through the shared item pipeline."
{:regular {}
:bold {:bold? true}
:italic {:italic? true}
:bold-italic {:bold? true, :italic? true}
:mono {:code? true}})
(defn text-block-height
"Vertical points a wrapped text block would consume (without drawing), bounded by `max-h`.
Uses the same single-styled-run item pipeline as [[draw-text-block!]]."
[face font-pt max-w max-h text]
(if (str/blank? (str text))
0.0
(let [lh (* font-pt common/line-height-factor)
runs [(merge {:text (str text)}
(get face-id->style (:id face)))]
lines (words->lines (runs->words runs) font-pt false max-w)
fit (-> (/ max-h lh) double Math/floor long (max 0))]
(min (lines-height lines font-pt)
(* fit lh)))))
;; --------------------------------------------------------------------------------------------
;; Parameters. The dashboard's active filter values are shown once at the very top, as a two-column
;; (name | value) table (like the email filter bar). A card's *inline* parameters render on the card
;; itself, flowing inline as `Name: value`. Both reuse the common text layout/draw pipeline; the only
;; bespoke piece is sizing the table's name column (see [[min-column-width]]).
;; --------------------------------------------------------------------------------------------
(defn min-column-width
"The narrowest width (<= `max-w`) at which `units` still pack into the *fewest* lines they would take at `max-w`.
This is the *bottleneck* variant of word-wrap: given that at least N lines are required, minimise the max width
for that fixed line count. We solve it by binary search using the greedy [[pack-units->lines]] as a monotonic
feasibility oracle. (Monotonic because a wider column never needs more lines.)
The search bottoms out at the widest single unit (= word).
Inexpensive -- a handful of greedy line wrap calls, with each unit's measurements cached (see [[font/*em-width*]])."
[units max-w split-fn]
(if (empty? units)
0.0
(let [lines-at (fn [w] (count (pack-units->lines units w split-fn)))
target (lines-at max-w)
lo0 (transduce (map :ww) max 0.0 units)]
(loop [lo lo0
hi (double max-w)]
(if (< (- hi lo) 0.5)
hi
(let [mid (/ (+ lo hi) 2.0)]
(if (<= (lines-at mid) target)
(recur lo mid)
(recur mid hi))))))))

View File

@@ -44,35 +44,63 @@
(.addStyleSheet nil (CSSNorm/formsStyleSheet) DOMAnalyzer$Origin/AGENT)
.getStyleSheets))
(defn- scale-px
"`px` scaled by `scale`, rounded *up* to a whole pixel so the scaled box never falls short of the
content it has to hold."
^long [px scale]
(long (Math/ceil (* (double px) (double scale)))))
(defn- redraw-at-scale!
"Re-render the already-laid-out boxes of `graphics-engine` into a fresh image `scale`x the device size
of `base`, for crisp supersampled output. Returns the new [[BufferedImage]]."
^java.awt.image.BufferedImage [^GraphicsEngine graphics-engine ^java.awt.image.BufferedImage base scale]
(let [big (BufferedImage. (scale-px (.getWidth base) scale)
(scale-px (.getHeight base) scale)
BufferedImage/TYPE_INT_ARGB)]
(.setImage graphics-engine big)
(.redrawBoxes graphics-engine)
big))
(defn- render-to-png
^java.awt.image.BufferedImage [^String html width]
(style/register-fonts-if-needed!)
(with-open [is (ByteArrayInputStream. (.getBytes html StandardCharsets/UTF_8))
doc-source (StreamDocumentSource. is nil "text/html; charset=utf-8")]
(let [dimension (Dimension. width 1)
doc (.parse (DefaultDOMSource. doc-source))
da (dom-analyzer doc doc-source dimension)
graphics-engine (proxy [GraphicsEngine] [(.getRoot da) da (.getURL doc-source)]
(setupGraphics [^Graphics2D g]
(doto g
(.setRenderingHint RenderingHints/KEY_RENDERING
RenderingHints/VALUE_RENDER_QUALITY)
(.setRenderingHint RenderingHints/KEY_ALPHA_INTERPOLATION
RenderingHints/VALUE_ALPHA_INTERPOLATION_QUALITY)
(.setRenderingHint RenderingHints/KEY_TEXT_ANTIALIASING
RenderingHints/VALUE_TEXT_ANTIALIAS_GASP)
(.setRenderingHint RenderingHints/KEY_FRACTIONALMETRICS
RenderingHints/VALUE_FRACTIONALMETRICS_ON))))]
(.createLayout graphics-engine dimension)
(let [image (.getImage graphics-engine)
viewport (.getViewport graphics-engine)
;; CSSBox voodoo -- sometimes maximal width < minimal width, no idea why
content-width (max (int (.getMinimalWidth viewport))
(int (.getMaximalWidth viewport)))]
;; Crop the image to the actual size of the rendered content so that tables don't have a ton of whitespace.
(if (< content-width (.getWidth image))
(.getSubimage image 0 0 content-width (.getHeight image))
image)))))
"Render `html` to a [[BufferedImage]] at `width` logical pixels. `scale` > 1 rasterizes to that many
device pixels for crispness (via scaled device graphics, since CSSBox ignores CSS `zoom`/`transform`)."
(^java.awt.image.BufferedImage [^String html width]
(render-to-png html width 1.0))
(^java.awt.image.BufferedImage [^String html width scale]
(style/register-fonts-if-needed!)
(with-open [is (ByteArrayInputStream. (.getBytes html StandardCharsets/UTF_8))
doc-source (StreamDocumentSource. is nil "text/html; charset=utf-8")]
(let [scale (double scale)
dimension (Dimension. width 1)
doc (.parse (DefaultDOMSource. doc-source))
da (dom-analyzer doc doc-source dimension)
graphics-engine (proxy [GraphicsEngine] [(.getRoot da) da (.getURL doc-source)]
(setupGraphics [^Graphics2D g]
(when (not= 1.0 scale)
(.scale g scale scale))
(doto g
(.setRenderingHint RenderingHints/KEY_RENDERING
RenderingHints/VALUE_RENDER_QUALITY)
(.setRenderingHint RenderingHints/KEY_ALPHA_INTERPOLATION
RenderingHints/VALUE_ALPHA_INTERPOLATION_QUALITY)
(.setRenderingHint RenderingHints/KEY_TEXT_ANTIALIASING
RenderingHints/VALUE_TEXT_ANTIALIAS_GASP)
(.setRenderingHint RenderingHints/KEY_FRACTIONALMETRICS
RenderingHints/VALUE_FRACTIONALMETRICS_ON))))]
(.createLayout graphics-engine dimension)
(let [base (.getImage graphics-engine)
viewport (.getViewport graphics-engine)
;; CSSBox voodoo -- sometimes maximal width < minimal width, no idea why
content-width (max (int (.getMinimalWidth viewport))
(int (.getMaximalWidth viewport)))
image (if (= 1.0 scale)
base
(redraw-at-scale! graphics-engine base scale))
crop-width (scale-px content-width scale)]
;; Crop the image to the actual size of the rendered content so that tables don't have a ton of whitespace.
(if (< crop-width (.getWidth image))
(.getSubimage image 0 0 crop-width (.getHeight image))
image))))))
(defn- font-can-fully-render?
[^Font font s]
@@ -133,6 +161,7 @@
(try
(let [padding-x (or (:channel.render/padding-x options) 0)
padding-y (or (:channel.render/padding-y options) 0)
scale (or (:channel.render/scale options) 1.0)
padding-css (str padding-y "px " padding-x "px")
;; Wrap content in a container div with padding to ensure padding is included in content-width
;; calculation (body padding gets cropped off by render-to-png)
@@ -149,7 +178,7 @@
:background-color :white})}
(wrap-non-lato-chars padded-content)]])]
(with-open [os (ByteArrayOutputStream.)]
(-> (render-to-png html width)
(-> (render-to-png html width scale)
(write-image! "png" os))
(.toByteArray os)))
(catch Throwable e

View File

@@ -262,10 +262,12 @@
(defn complete!
"Completes the file upload to a Slack channel by calling the `files.completeUploadExternal` endpoint, and polls the
same endpoint until the file is uploaded to the channel. Returns the URL of the uploaded file."
[& {:keys [file-id filename]}]
[& {:keys [file-id filename channel-id initial-comment]}]
(let [complete! (fn []
(POST "files.completeUploadExternal"
{:query-params {:files (json/encode [{:id file-id, :title filename}])}}))
{:query-params (cond-> {:files (json/encode [{:id file-id, :title filename}])}
channel-id (assoc :channel_id channel-id)
initial-comment (assoc :initial_comment initial-comment))}))
complete-response
(try
(complete!)
@@ -312,6 +314,35 @@
:id file_id}
(log/debug "Uploaded image" (:url <>)))))
(defn- join-channel!
"Best-effort: have the bot join `channel-id`. Sharing a file into a channel requires membership (Slack returns
`not_in_channel` otherwise); posting messages does not, since that uses `chat:write.public`. Joining only works for
public channels — private channels need the bot invited, so a failure here is logged and ignored."
[channel-id]
(try
(POST "conversations.join" {:query-params {:channel channel-id}})
(catch Throwable e
(log/warnf e "Could not join Slack channel %s; a file share may fail" channel-id))))
(mu/defn upload-file-to-channel!
"Upload `file` bytes to Slack and share them into `channel-id` as a downloadable file. Returns the uploaded file URL.
Joins the channel first, since file sharing requires membership. `initial-comment` (mrkdwn, optional) becomes the
text of the file's message, so the file and a caption arrive as a single message."
([file filename channel-id]
(upload-file-to-channel! file filename channel-id nil))
([file :- NonEmptyByteArray
filename :- ms/NonBlankString
channel-id :- ms/NonBlankString
initial-comment :- [:maybe :string]]
{:pre [(channel.settings/slack-configured?)]}
(join-channel! channel-id)
(let [{:keys [upload_url file_id]} (get-upload-url! filename file)]
(upload-file-to-url! upload_url file)
(complete! {:file-id file_id
:filename filename
:channel-id channel-id
:initial-comment initial-comment}))))
(mu/defn post-chat-message!
"Calls Slack API `chat.postMessage` endpoint and posts a message to a channel.
message-blocks if provided should be a map containing slack message blocks

View File

@@ -11,6 +11,7 @@
[metabase.api.macros :as api.macros]
[metabase.app-db.core :as app-db]
[metabase.channel.email.messages :as messages]
[metabase.channel.render.core :as channel.render]
[metabase.collections-rest.api :as api.collection]
[metabase.collections.core :as collections]
[metabase.collections.models.collection :as collection]
@@ -50,6 +51,7 @@
[metabase.util.json :as json]
[metabase.util.log :as log]
[metabase.util.malli :as mu]
[metabase.util.malli.registry :as mr]
[metabase.util.malli.schema :as ms]
[metabase.xrays.core :as xrays]
[ring.util.codec :as codec]
@@ -58,6 +60,19 @@
(set! *warn-on-reflection* true)
;;; TODO -- why don't we use [[metabase.util.malli.schema/Parameter]] for this? Are the parameters passed here
;;; different?
(def ParameterWithID
"Schema for a parameter map with an string `:id`. This is the shape of runtime parameter *overrides* the FE sends
when running a dashcard query or exporting a dashboard -- distinct from the stored parameter declarations
(`::parameters.schema/parameters`), which also require `:type`."
(mu/with-api-error-message
[:and
[:map
[:id ms/NonBlankString]]
[:map-of :keyword :any]]
(deferred-tru "value must be a parameter map with an ''id'' key")))
(defn- dashboards-list [filter-option]
(as-> (t2/select :model/Dashboard {:where [:and (case (or (keyword filter-option) :all)
(:all :archived) true
@@ -630,6 +645,50 @@
(u/prog1 (first (revisions/with-last-edit-info [dashboard] :dashboard))
(events/publish-event! :event/dashboard-read {:object-id (:id dashboard) :user-id api/*current-user-id*})))))
#_{:clj-kondo/ignore [:metabase/validate-defendpoint-has-response-schema]}
(api.macros/defendpoint :post "/:id/pdf" :- :any
"Render Dashboard with ID to a PDF (server-side, the same way dashboard subscriptions render charts) and stream it
back as a file download.
`parameters` are runtime parameter overrides -- the same shape the dashcard query endpoints accept: a sequence of
`{:id ... :value ...}` maps, or a JSON-encoded string of that sequence (the string form lets an HTML `<form>`
action drive the download). Parameters left unspecified fall back to the dashboard's own defaults.
`paper_size` is `\"a4\"` (default) or `\"letter\"`."
[{:keys [id]} :- [:map
[:id ms/PositiveInt]]
_query-params
{:keys [parameters paper_size]} :- [:map
[:parameters {:optional true} [:maybe [:or
[:sequential ParameterWithID]
;; JSON-string form for <form>-driven
;; downloads, mirroring the dashcard
;; export-format endpoint
ms/JSONString]]]
[:paper_size {:default "a4"} [:maybe [:enum "a4" "letter"]]]]
_request
respond
raise]
(try
(let [dashboard (api/read-check :model/Dashboard id)
params (cond-> parameters
(string? parameters) json/decode+kw)
;; the array form is validated by the endpoint schema, but a JSON string only proves it's *valid JSON* --
;; once decoded it must still be a well-formed parameter list, or it's a 400 (not a 500)
_ (api/check-400 (mr/validate [:maybe [:sequential ParameterWithID]] params))
pdf-bytes (channel.render/render-dashboard-to-pdf id api/*current-user-id*
(or params [])
(keyword (or paper_size "a4")))
filename (str (or (not-empty (u/slugify (:name dashboard)))
(str "dashboard-" id))
".pdf")]
(respond {:status 200
:headers {"Content-Type" "application/pdf"
"Content-Disposition" (format "attachment; filename=\"%s\"" filename)}
:body (java.io.ByteArrayInputStream. pdf-bytes)}))
(catch Throwable e
(raise e))))
;; TODO (Cam 2025-11-25) please add a response schema to this API endpoint, it makes it easier for our customers to
;; use our API + we will need it when we make auto-TypeScript-signature generation happen
;;
@@ -1330,17 +1389,6 @@
(into {} (for [field-id filtered-field-ids]
[field-id (sort (chain-filter/filterable-field-ids field-id filtering-field-ids))]))))
;;; TODO -- why don't we use [[metabase.util.malli.schema/Parameter]] for this? Are the parameters passed here
;;; different?
(def ParameterWithID
"Schema for a parameter map with an string `:id`."
(mu/with-api-error-message
[:and
[:map
[:id ms/NonBlankString]]
[:map-of :keyword :any]]
(deferred-tru "value must be a parameter map with an ''id'' key")))
;;; ---------------------------------- Executing the action associated with a Dashcard -------------------------------
;; TODO (Cam 2025-11-25) please add a response schema to this API endpoint, it makes it easier for our customers to

View File

@@ -15,8 +15,11 @@
(p/import-vars
[notification.payload.execute
dashcard-link-card->part
execute-dashboard
process-virtual-dashcard]
execute-dashboard-subscription-card
process-virtual-dashcard
virtual-card-of-type?]
[notification.payload.temp-storage
cleanup!
cleanable?])

View File

@@ -84,10 +84,12 @@
(format "\n%s" description)))
:type :text}))
(defn- dashcard-link-card->part
"Convert a dashcard that is a link card to pulse part.
(defn dashcard-link-card->part
"Convert a dashcard that is a link card into a `:text` part (markdown `### [name](url)`), or nil
if it links to an entity the current user can't read. Used by both the email/notification
pipeline and the backend PDF renderer.
This function should be executed under pulse's creator permissions."
This function should be executed under pulse's creator permissions (`with-current-user`)."
[dashcard]
(assert api/*current-user-id* "Makes sure you wrapped this with a `with-current-user`.")
(let [link-card (get-in dashcard [:visualization_settings :link])]

View File

@@ -58,7 +58,8 @@
{:channel_type channel-type
:channel channel
:recipients (channel-recipients pulse-channel)
:attachment_only (boolean (get-in pulse-channel [:details :attachment_only]))}))
:attachment_only (boolean (get-in pulse-channel [:details :attachment_only]))
:include_pdf (boolean (get-in pulse-channel [:details :include_pdf]))}))
(defn- maybe-name [x] (some-> x name))

View File

@@ -1,13 +1,23 @@
(ns metabase.util.http
(:require
[clj-http.client :as http]
[clojure.string :as str]
[medley.core :as m]
[metabase.util.json :as json])
(:import
(java.net InetAddress URL)))
(com.google.common.net InetAddresses)
(java.io ByteArrayOutputStream InputStream)
(java.net Inet6Address InetAddress URL)
(java.util Locale)
(org.apache.http.conn DnsResolver)
(org.apache.http.impl.conn SystemDefaultDnsResolver)))
(set! *warn-on-reflection* true)
;; A local locale-independent lower-case (equivalent to `metabase.util/lower-case-en`); inlined to
;; avoid a load cycle, since `metabase.util` transitively requires this namespace.
(defn- lower-case-en ^String [s] (.toLowerCase (str s) Locale/ENGLISH))
(defn- parse-http-headers [headers]
(json/decode headers))
@@ -41,3 +51,125 @@
;; Only block site-local (private) addresses for :external-only
(or (= strategy :allow-private)
(not (.isSiteLocalAddress host-name)))))))
;; --------------------------------------------------------------------------------------------
;; SSRF-hardened fetch of an untrusted (user-provided) URL.
;;
;; Fetching a user-provided URL server-side is the classic SSRF risk. Defenses:
;; - HTTPS only; reject IP-literal hosts and localhost/metadata/internal hostnames.
;; - Validate every *resolved* IP is a public unicast address via a custom DnsResolver -- this
;; runs inside the connection the client actually opens, closing the DNS-rebinding TOCTOU gap.
;; It rejects loopback, link-local (incl. cloud metadata 169.254.169.254), site-local (RFC1918),
;; any-local, multicast, IPv6 ULA (fc00::/7), and IPv4 CGNAT (100.64/10).
;; - No redirects (a 3xx would be a bypass vector; here it just fails).
;; - No cookies/credentials (a fresh clj-http GET carries no Metabase session).
;; - Cap the download bytes and (optionally) restrict to an allowlist of content-types.
;;
;; TODO (bshepherdson 2026-06-09) -- this hardened fetch (rebinding-safe [[ssrf-safe-dns-resolver]]
;; + [[public-address?]] + size/content-type caps) supersedes the weaker [[valid-host?]] above,
;; which validates only a single up-front DNS resolution (a TOCTOU/DNS-rebinding gap) and misses
;; IPv6 ULA, IPv4 CGNAT, any-local, multicast, and IP-literal hosts. Migrate the existing
;; `valid-host?` callers -- `metabase.geojson`, `metabase.sso.oidc.http`,
;; `metabase.channel.impl.http` -- onto this, and add SSRF validation to
;; `metabase.actions.http-action` (which currently has none).
;; --------------------------------------------------------------------------------------------
(def ^:private fetch-default-timeout-ms 8000)
(def ^:private fetch-default-max-bytes (* 20 1024 1024))
;; A descriptive User-Agent: some hosts (e.g. Wikimedia) return 403 for default library UAs.
(def ^:private fetch-default-user-agent "Metabase (+https://www.metabase.com)")
(def ^:private blocked-fetch-hosts #{"localhost" "metadata" "metadata.google.internal"})
(def ^:private blocked-fetch-host-suffixes [".localhost" ".local" ".internal" ".lan" ".home.arpa"])
(defn public-address?
"True only for globally-routable unicast IP addresses (rejects loopback, link-local, site-local,
any-local, multicast, IPv6 unique-local fc00::/7, and IPv4 CGNAT 100.64.0.0/10)."
[^InetAddress addr]
(let [b (.getAddress addr)]
(not (or (.isLoopbackAddress addr)
(.isLinkLocalAddress addr)
(.isSiteLocalAddress addr)
(.isAnyLocalAddress addr)
(.isMulticastAddress addr)
(and (instance? Inet6Address addr) ; IPv6 unique-local fc00::/7
(= 0xfc (bit-and (aget b 0) 0xfe)))
(and (= 4 (alength b)) ; IPv4 CGNAT 100.64.0.0/10
(= 100 (bit-and (aget b 0) 0xff))
(<= 64 (bit-and (aget b 1) 0xff) 127))))))
(def ^DnsResolver ^:private ssrf-safe-dns-resolver
"A `DnsResolver` that resolves normally but throws unless *every* resolved address is public.
Used as clj-http's `:dns-resolver` so the check runs inside the connection actually opened,
closing the DNS-rebinding TOCTOU gap (validating up front then re-resolving would not)."
(let [system (SystemDefaultDnsResolver.)]
(reify DnsResolver
(^"[Ljava.net.InetAddress;" resolve [_ ^String host]
(let [addrs (.resolve system host)]
(if (every? public-address? addrs)
addrs
(throw (ex-info "Refusing to fetch from a non-public address" {:ssrf true}))))))))
(defn safe-url?
"True if `url` is safe to fetch from untrusted input: HTTPS scheme, no userinfo, and a real DNS
hostname (not an IP literal, not localhost/metadata/internal). Note this is a cheap pre-check;
the resolved-IP validation in [[ssrf-safe-dns-resolver]] is what closes the rebinding gap."
[^String url]
(try
(let [parsed (URL. url)
host (some-> (.getHost parsed) lower-case-en (str/replace #"^\[|\]$" ""))]
(and (= "https" (lower-case-en (str (.getProtocol parsed))))
(str/blank? (str (.getUserInfo parsed)))
(not (str/blank? host))
(boolean (re-find #"[a-z]" host)) ; a real hostname has a letter; blocks decimal/octal IP forms
(not (InetAddresses/isInetAddress host))
(not (contains? blocked-fetch-hosts host))
(not (some #(str/ends-with? host %) blocked-fetch-host-suffixes))))
(catch Throwable _ false)))
(defn- read-bounded
"Read up to `max` bytes from `in`; returns the byte[] or nil if the stream exceeds `max`."
^bytes [^InputStream in max]
(let [out (ByteArrayOutputStream.)
buf (byte-array 8192)]
(loop [total 0]
(let [n (.read in buf)]
(cond
(neg? n) (.toByteArray out)
(> (+ total n) max) nil
:else (do (.write out buf 0 n) (recur (+ total n))))))))
(defn fetch-bytes
"SSRF-hardened GET of `url`. Returns `{:bytes <byte[]> :content-type <lower-cased string>}` on a
200 response whose (parameter-stripped, lower-cased) content-type is allowed and whose body is
within the byte cap; otherwise nil. Never throws -- on any failure (unsafe URL, network error,
non-200, disallowed content-type, oversized body) it returns nil, so callers can fall back.
Defenses are described in the section comment above. Options:
:allowed-content-types set of lower-cased content-types to accept; nil/empty accepts any
:max-bytes download cap in bytes (default 20 MB)
:timeout-ms socket + connection timeout (default 8000)
:user-agent `User-Agent` header (default a descriptive Metabase UA)"
([url] (fetch-bytes url nil))
([url {:keys [allowed-content-types max-bytes timeout-ms user-agent]
:or {max-bytes fetch-default-max-bytes
timeout-ms fetch-default-timeout-ms
user-agent fetch-default-user-agent}}]
(when (safe-url? url)
(try
(let [resp (http/get url {:as :stream
:redirect-strategy :none
:socket-timeout timeout-ms
:connection-timeout timeout-ms
:throw-exceptions false
:headers {"User-Agent" user-agent}
:dns-resolver ssrf-safe-dns-resolver})
ctype (some-> (get-in resp [:headers :content-type])
(str/split #";") first str/trim lower-case-en)
^InputStream body (:body resp)]
(try
(when (and (= 200 (:status resp))
(or (empty? allowed-content-types) (contains? allowed-content-types ctype)))
(when-let [bytes (read-bounded body max-bytes)]
{:bytes bytes :content-type ctype}))
(finally (some-> body .close))))
(catch Throwable _ nil)))))

View File

@@ -0,0 +1,35 @@
(ns metabase.channel.render.pdf.markdown-test
"Tests for the Markdown -> styled-runs/blocks parsing that feeds the backend PDF renderer."
(:require
[clojure.test :refer :all]
[metabase.channel.render.pdf.markdown :as md]))
;; --------------------------------------------------------------------------------------------
;; Furigana {base|reading} parsing
;; --------------------------------------------------------------------------------------------
(deftest ^:parallel parse-ruby-test
(let [strip (fn [runs]
(mapv #(dissoc % :href) runs))]
(testing "{base|reading} becomes a ruby run; surrounding text stays as text runs"
(is (= [{:ruby? true
:base "参加希望"
:reading "さんかきぼう"}
{:text "の方は"}]
(strip (#'md/parse-ruby "{参加希望|さんかきぼう}の方は" {} nil)))))
(testing "multiple furigana groups interleaved with text"
(is (= [{:text "a"} {:ruby? true :base "x" :reading "y"}
{:text "b"} {:ruby? true :base "c" :reading "d"}]
(strip (#'md/parse-ruby "a{x|y}b{c|d}" {} nil)))))
(testing "plain text without ruby is a single text run"
(is (= [{:text "hello world"}]
(strip (#'md/parse-ruby "hello world" {} nil)))))
(testing "braces without a pipe are not treated as ruby"
(is (= [{:text "{not ruby}"}]
(strip (#'md/parse-ruby "{not ruby}" {} nil)))))
(testing "the run's style keys are carried onto base/reading runs"
(is (= [{:bold? true
:ruby? true
:base "x"
:reading "y"}]
(strip (#'md/parse-ruby "{x|y}" {:bold? true} nil)))))))

View File

@@ -0,0 +1,730 @@
(ns metabase.channel.render.pdf-test
"Tests for backend dashboard->PDF rendering: the pure layout/text helpers (Markdown furigana,
parameter chip wrapping, inline-parameter resolution, visualizer display resolution, and the
right-to-left shaping/reordering/alignment for Arabic and Hebrew). Everything here is
intentionally network-free (pure predicates and in-memory PDFs) so the suite is fast and not
flaky. The SSRF-safe image-fetch defenses now live in `metabase.util.http` (see
`metabase.util.http-test`)."
(:require
[clojure.core.memoize :as ccm]
[clojure.java.io :as io]
[clojure.string :as str]
[clojure.test :refer :all]
[metabase.api.common :as api]
[metabase.channel.render.card :as render.card]
[metabase.channel.render.pdf :as pdf]
[metabase.channel.render.pdf.font :as font]
[metabase.channel.render.pdf.typeset :as typeset]
[metabase.test.util.dynamic-redefs :as dynamic-redefs]
[metabase.util.memoize :as memo])
(:import
(java.awt Color)
(java.awt.geom PathIterator Rectangle2D)
(java.awt.image BufferedImage)
(java.io ByteArrayOutputStream StringReader)
(javax.imageio ImageIO)
(org.apache.batik.parser AWTPathProducer)
(org.apache.pdfbox.pdmodel PDDocument PDPage PDPageContentStream)
(org.apache.pdfbox.pdmodel.common PDRectangle)
(org.apache.pdfbox.pdmodel.interactive.action PDActionURI)
(org.apache.pdfbox.pdmodel.interactive.annotation PDAnnotationLink)))
(set! *warn-on-reflection* true)
;; --------------------------------------------------------------------------------------------
;; Clickable links
;; --------------------------------------------------------------------------------------------
(deftest ^:parallel clickable-href?-test
(testing "absolute http/https/mailto links are clickable"
(doseq [h ["https://example.com" "http://example.com/a?b=1" "HTTPS://EX.COM" "mailto:a@b.com"]]
(is (true? (boolean (#'pdf/clickable-href? h)))
(str h))))
(testing "relative, scheme-less, and dangerous schemes are not clickable"
(doseq [h ["/dashboard/5" "example.com" "javascript:alert(1)" "file:///etc/passwd" "ftp://x/y" "" nil]]
(is (false? (boolean (#'pdf/clickable-href? h)))
(str h)))))
;; not ^:parallel: exercises the real PDFBox rendering path (PDDocument + font loading + drawing),
;; which the deftest linter treats as side-effecting
(deftest ^:synchronized link-annotations-test
(testing "markdown links become PDF URI link annotations (http/https/mailto only)"
(with-open [doc (PDDocument.)]
(let [page (PDPage. PDRectangle/A4)]
(.addPage doc page)
(let [cs (PDPageContentStream. doc page)
md "[docs](https://metabase.com) [mail](mailto:a@b.com) [rel](/x) [js](javascript:1)"]
(binding [font/*fonts* (#'font/load-fonts! doc)
pdf/*link-rects* (atom [])]
(#'pdf/draw-markdown-in-cell! doc cs 40.0 800.0 500.0 100.0 md)
(.close cs)
(#'pdf/add-link-annotations! page @pdf/*link-rects*)))
;; "rel" (relative) and "js" (javascript:) must not be clickable
(is (= #{"https://metabase.com" "mailto:a@b.com"}
(->> (.getAnnotations page)
(filter (partial instance? PDAnnotationLink))
(map #(.getURI ^PDActionURI (.getAction ^PDAnnotationLink %)))
set)))))))
;; --------------------------------------------------------------------------------------------
;; "Made with Metabase" vector branding badge (logo SVG paths + Lato-outlined "Made with")
;; --------------------------------------------------------------------------------------------
(deftest ^:parallel branding-logo-asset-test
(testing "the logo SVG parses to three fill groups with the brand colors and non-empty paths"
(let [{:keys [vw vh groups]} @@#'pdf/brand-logo]
(is (= 87.0 vw))
(is (= 22.0 vh))
(is (= 3 (count groups)))
(is (every? (comp seq :ds) groups) "every fill group has at least one path")
(is (= #{(Color. 0x5A 0x60 0x72)
(Color. 0x50 0x9E 0xE3)
(Color. 0xC2 0xDA 0xF0)}
(set (map :color groups)))))))
(deftest ^:parallel branding-svg-paths-drawable-test
(testing "every logo path parses to a non-empty shape (Batik flattens arcs to beziers)"
(doseq [{:keys [ds]} (:groups @@#'pdf/brand-logo)
d ds]
(let [shape (AWTPathProducer/createShape (StringReader. d) PathIterator/WIND_NON_ZERO)
b ^Rectangle2D (.getBounds2D shape)]
(is (pos? (.getWidth b)) (str "path has width: " d))
(is (pos? (.getHeight b)) (str "path has height: " d))))))
;; not ^:parallel: exercises the real PDFBox rendering path (font registry + content-stream text and
;; SVG-vector logo), which the deftest linter treats as side-effecting
(deftest ^:synchronized branding-badge-render-smoke-test
(testing "draw-brand-badge! draws the localized prefix (body face) + SVG-vector logo into a saveable PDF"
(with-open [doc (PDDocument.)]
(binding [font/*fonts* (#'font/load-fonts! doc)]
(let [page (PDPage. PDRectangle/A4)]
(.addPage doc page)
(with-open [cs (PDPageContentStream. doc page)]
;; right edge near the A4 content margin, badge in the top margin band
(#'pdf/draw-brand-badge! cs 559.0 800.0))
(let [baos (ByteArrayOutputStream.)]
(.save doc baos)
(is (pos? (count (.toByteArray baos))))))))))
(defn- last-non-stroking-color
"The operands of the last non-stroking colour operator (`r g b sc`) in a page's content stream, as a vector of
doubles -- i.e. the fill colour left in effect at the end of drawing. nil when none was set."
[^PDPage page]
(let [content (slurp (.getContents page) :encoding "ISO-8859-1")
colors (re-seq #"([0-9.]+)\s+([0-9.]+)\s+([0-9.]+)\s+sc\b" content)]
(some->> (last colors) rest (mapv #(Double/parseDouble %)))))
;; not ^:parallel: exercises the real PDFBox content-stream + font registry, which the deftest linter treats as
;; side-effecting
(deftest ^:synchronized header-tab-title-color-test
(testing "the tab title is drawn in black, not the gray left over from the parameter values above it"
(with-open [doc (PDDocument.)]
(binding [font/*fonts* (#'font/load-fonts! doc)
pdf/*link-rects* (atom [])]
(let [page (PDPage. PDRectangle/A4)
_ (.addPage doc page)
;; one parameter so a gray value is drawn just before the tab title; the tab title sets no colour of
;; its own, so it inherits whatever the parameter table left behind
tbl (#'pdf/layout-param-table [{:name "Category" :value "Gadget"}] 500.0 36.0)]
(with-open [cs (PDPageContentStream. doc page)]
(#'pdf/draw-header! cs 841.89 500.0 {:dashboard-title "Dash"
:param-table tbl
:tab-title "Overview"}))
;; the last fill colour set while drawing the header (the tab title sets none) must be black -- before the
;; fix draw-param-table! leaves it at body-color gray (~0.42 0.45 0.50)
(let [color (last-non-stroking-color page)]
(is (some? color) "the header set a non-stroking colour")
(is (every? #(< % 0.01) color)
"draw-param-table! must restore black so the tab title isn't drawn gray")))))))
;; --------------------------------------------------------------------------------------------
;; `segment` -- greedy streaming grouping transducer
;; --------------------------------------------------------------------------------------------
(deftest ^:parallel segment-test
;; pack numbers into groups whose running sum stays <= limit (greedy bin packing); ::typeset/reject
;; uses the alias so it resolves to the sentinel in the typeset namespace.
(let [pack (fn [limit]
(#'typeset/segment
(fn
([x] {:items [x]
:sum x})
([{:keys [items sum]} x]
(let [s (+ sum x)]
(if (<= s limit)
{:items (conj items x)
:sum s}
::typeset/reject))))
:items))]
(testing "greedy grouping by running total; an overflowing item reopens the next group"
(is (= [[3 4] [5 5] [2]]
(into [] (pack 10) [3 4 5 5 2]))))
(testing "no rejection -> a single group"
(is (= [[1 2 3]]
(into [] (pack 100) [1 2 3]))))
(testing "empty input -> no groups"
(is (= []
(into [] (pack 10) []))))
(testing "default close is identity (emits the raw accumulator)"
(is (= [[1 2 3]]
(into [] (#'typeset/segment (fn
([x] [x])
([acc x] (conj acc x))))
[1 2 3]))))
(testing "custom close transforms each finished group"
(is (= [3 7 5] ; consecutive pairs, summed
(into [] (#'typeset/segment (fn
([x] [x])
([acc x]
(if (< (count acc) 2)
(conj acc x)
::typeset/reject)))
#(reduce + %))
[1 2 3 4 5]))))
(testing "::break closes the group and DROPS the seam item"
(is (= [[1 2] [3] [4]]
(into [] (#'typeset/segment (fn
([x] [x])
([acc x]
(if (= x :|)
::typeset/break
(conj acc x)))))
[1 2 :| 3 :| 4]))))
(testing "honors downstream reduced? (take) without double-flushing or erroring"
(is (= [[3 4]]
(into [] (comp (pack 10)
(take 1))
[3 4 5 5 2])))
(is (= [[3 4] [5 5]]
(into [] (comp (pack 10)
(take 2))
[3 4 5 5 2]))))
(testing "composes/streams via sequence too, flushing the final open group"
(is (= [[3 4] [5 5] [2]]
(sequence (pack 10) [3 4 5 5 2]))))))
(deftest ^:parallel segment-partition-all-test
(letfn [(part-all [n]
(#'typeset/segment (fn
([item] [item])
([acc item]
(if (= n (count acc))
::typeset/reject
(conj acc item))))))]
(are [n xs] (= (into [] (partition-all n) xs)
(into [] (part-all n) xs))
3 (range 10)
3 (range 11)
3 (range 12)
3 (range 13)
1 (range 10)
1 (range 1)
3 []
1 [])))
(deftest ^:parallel segment-partition-by-test
(letfn [(part-by [kf]
(#'typeset/segment (fn
([item] [(kf item) [item]])
([[old-key acc] item]
(let [new-key (kf item)]
(if (not= old-key new-key)
::typeset/reject
[old-key (conj acc item)]))))
second))]
(are [xs] (= (into [] (partition-by even?) xs)
(into [] (part-by even?) xs))
(range 10)
(range 12)
(range 1)
[]
[1 3 5 4 2 6 7 9 10 11 12])))
;; --------------------------------------------------------------------------------------------
;; Link cards render as clickable markdown text cells
;; --------------------------------------------------------------------------------------------
(deftest ^:parallel link-card-cell-test
(testing "a URL link dashcard becomes a markdown text cell (clickable like any md link)"
(binding [api/*current-user-id* 1]
(is (= {:row 0
:col 0
:size_x 2
:size_y 1
:kind :text
:text "### [https://example.com](https://example.com)"}
(#'pdf/dashcard->cell {:row 0
:col 0
:size_x 2
:size_y 1
:visualization_settings {:virtual_card {:display "link"}
:link {:url "https://example.com"}}}
[]))))))
(def ^:private iframe-html
"<iframe width=\"560\" src=\"https://www.youtube.com/embed/x\" allowfullscreen></iframe>")
(deftest ^:parallel iframe-url-test
(testing "a bare URL is used as-is"
(is (= "https://youtu.be/x"
(#'pdf/iframe-url "https://youtu.be/x"))))
(testing "src is extracted from an <iframe> embed snippet"
(is (= "https://www.youtube.com/embed/x"
(#'pdf/iframe-url iframe-html))))
(testing "a scheme-less URL gets https://"
(is (= "https://vimeo.com/123"
(#'pdf/iframe-url "vimeo.com/123")))
(is (= "https://vimeo.com/123"
(#'pdf/iframe-url "//vimeo.com/123"))))
(testing "blank/nil or an empty iframe yields nil"
(is (nil? (#'pdf/iframe-url nil)))
(is (nil? (#'pdf/iframe-url " ")))
(is (nil? (#'pdf/iframe-url "<iframe></iframe>")))))
(deftest ^:parallel iframe-card-cell-test
(testing "an iframe dashcard becomes a clickable link text cell to its target"
(is (= {:row 0
:col 0
:size_x 4
:size_y 3
:kind :text
:text "https://youtu.be/abc"}
(#'pdf/dashcard->cell {:row 0
:col 0
:size_x 4
:size_y 3
:visualization_settings {:virtual_card {:display "iframe"}
:iframe "https://youtu.be/abc"}}
[])))))
;; --------------------------------------------------------------------------------------------
;; Parameter name-column sizing (min-column-width)
;; --------------------------------------------------------------------------------------------
(deftest ^:parallel min-column-width-test
;; synthetic pre-measured units (no fonts needed): three units of width 30/40/50, each with a
;; 5pt leading space; an identity split-fn (nothing is splittable).
(let [unit (fn [ww sb?]
{:ww ww
:sp 5.0
:space-before? sb?})
units [(unit 30.0 false) (unit 40.0 true) (unit 50.0 true)]
n-at (fn [w]
(count (#'typeset/pack-units->lines units w identity)))]
(testing "returns the narrowest width that preserves the fewest-lines count"
(let [w1 (#'typeset/min-column-width units 1000.0 identity)] ; everything fits on one line when wide
(is (= 1 (n-at 1000.0)))
(is (= 1 (n-at w1))) ; still one line at the computed width
(is (= 2 (n-at (- w1 1.0)))) ; ... two lines just below it
(is (<= 129.0 w1 131.0))) ; == total advance 30 + (5+40) + (5+50)
(let [w2 (#'typeset/min-column-width units 80.0 identity)] ; only two units fit per line at 80
(is (= 2 (n-at 80.0)))
(is (= 2 (n-at w2)))
(is (<= 74.0 w2 76.0)))) ; == width to fit [30,40]: 30 + 5 + 40
(testing "empty units -> zero width"
(is (= 0.0 (#'typeset/min-column-width [] 100.0 identity))))))
(deftest ^:parallel resolve-inline-params-test
(let [params [{:id "a" :name "Max Discount" :value [100]}
{:id "b" :name "Category" :value "Gadget"}
{:id "c" :name "Unset"} ; no value -> dropped
{:id "d" :name "Top level"}]]
(testing "only the dashcard's inline parameter ids that carry a value are returned, in param order"
(is (= [{:id "a"
:name "Max Discount"
:value [100]}]
(#'pdf/resolve-inline-params {:inline_parameters ["a" "c"]} params))))
(testing "multiple inline params preserve their order in the parameter list"
(is (= ["b" "a"]
(mapv :id (#'pdf/resolve-inline-params {:inline_parameters ["a" "b"]}
(reverse params))))))
(testing "no inline parameters -> nil (so cells stay free of the key)"
(is (nil? (#'pdf/resolve-inline-params {} params)))
(is (nil? (#'pdf/resolve-inline-params {:inline_parameters []} params))))
(testing "an inline id that only has an unset parameter -> nil"
(is (nil? (#'pdf/resolve-inline-params {:inline_parameters ["c"]} params))))))
(deftest ^:parallel non-default-parameters-test
;; The header filter bar should list only parameters the viewer actually changed -- a parameter sitting at its
;; default conveys nothing and shouldn't reserve (or fill) header space. Query filtering still applies defaults;
;; this is display-only.
(let [dash {:parameters [{:id "a" :name "A" :type "string/=" :default "x"}
{:id "b" :name "B" :type "string/="} ; no default
{:id "c" :name "C" :type "string/=" :default "z"}]}]
(testing "no overrides -> nothing shown (every valued parameter is just its default)"
(is (= [] (#'pdf/non-default-parameters dash []))))
(testing "an override that differs from the default is kept (and :default is dropped, :value set)"
(is (= [{:id "a" :name "A" :type "string/=" :value "OVERRIDE"}]
(#'pdf/non-default-parameters dash [{:id "a" :value "OVERRIDE"}]))))
(testing "an override equal to the parameter's default is dropped"
(is (= [] (#'pdf/non-default-parameters dash [{:id "c" :value "z"}]))))
(testing "a value on a parameter with no default is kept"
(is (= ["B"] (mapv :name (#'pdf/non-default-parameters dash [{:id "b" :value "v"}])))))))
(deftest ^:parallel pages-for-section-tab-title-test
;; A tabbed dashboard reserves header space for the tab title; the first page must actually carry the title (under
;; the :tab-title key draw-header! reads) so the reserved space is drawn, not left blank.
(let [dims {:unit 21.8 :rows 35 :rect PDRectangle/A4}
section {:tab-name "Overview" :cells [{:row 0 :col 0 :size_x 12 :size_y 4 :kind :card}]}]
(testing "tabbed section's first page carries the dashboard title + tab title and reserves header space"
(let [page (first (#'pdf/pages-for-section section 0 dims true "My Dash" nil))]
(is (= "My Dash" (:dashboard-title page)))
(is (= "Overview" (:tab-title page)))
(is (pos? (#'pdf/header-height page)))
;; the grid takes only the rows that fit below the header -- fewer than a full page
(is (< (:rows page) (:rows dims)))))
(testing "later tab sections carry their tab title but not the dashboard title"
(let [page (first (#'pdf/pages-for-section section 1 dims true "My Dash" nil))]
(is (nil? (:dashboard-title page)))
(is (= "Overview" (:tab-title page)))))
(testing "an untabbed render (a lone tab) draws no tab title, so its header is shorter and leaves more grid rows"
;; render-dashboard-to-pdf passes tabbed?=false when there's <= 1 tab, so a single-tab dashboard doesn't
;; get a (UI-hidden) tab title or its reserved space
(let [tabbed (first (#'pdf/pages-for-section section 0 dims true "My Dash" nil))
untabbed (first (#'pdf/pages-for-section section 0 dims false "My Dash" nil))]
(is (nil? (:tab-title untabbed)))
(is (= "My Dash" (:dashboard-title untabbed)))
(is (< (#'pdf/header-height untabbed) (#'pdf/header-height tabbed)))
(is (>= (:rows untabbed) (:rows tabbed)))))))
(deftest ^:parallel visual-order-test
(testing "left-to-right text is returned unchanged (no bidi processing)"
(is (= "Hello, world 123" (#'font/visual-order "Hello, world 123")))
(is (= "" (#'font/visual-order "")))
(is (= "日本語" (#'font/visual-order "日本語"))))
(testing "Hebrew (non-joining) is reordered logical->visual, i.e. reversed, so a left-to-right
renderer draws it right-to-left; the letters themselves are unchanged"
(let [shalom "שלום"] ; שלום
(is (= (apply str (reverse shalom))
(#'font/visual-order shalom)))))
(testing "Arabic is shaped to positional presentation forms (Presentation-Forms-B / -A) and
reordered to visual order"
(let [marhaba "مرحبا" ; مرحبا, base (non-presentation) letters
out (#'font/visual-order marhaba)]
;; every output char is a joined presentation form, none is a bare base letter
(is (every? (fn [c]
(or (<= 0xFB50 (int c) 0xFDFF)
(<= 0xFE70 (int c) 0xFEFF)))
out))
(is (not-any? (fn [c] (<= 0x0600 (int c) 0x06FF)) out))
(is (not (re-find #"\?" out)))))
(testing "Arabic vowel marks (tashkeel) are dropped, leaving the bare consonant skeleton
(no zero-width combining marks, no spacing FE70-block tashkeel that would float)"
;; مُحَمَّدٌ (vocalised 'Muhammad') and مرحبا shape to the same consonants once marks are gone
(let [vocalised (#'font/visual-order "مُحَمَّدٌ")]
(is (= 4 (count vocalised))) ; د م ح م, no marks left
(is (not-any? (fn [c] (<= 0xFE70 (int c) 0xFE7F)) vocalised)) ; no spacing tashkeel forms
(is (= (#'font/visual-order "مرحبا")
(#'font/visual-order "مَرْحَبًا")))))) ; vocalised == plain once stripped
(deftest ^:parallel base-rtl?-test
(testing "base direction is RTL only when the first strong character is RTL"
(are [exp s] (= exp (boolean (#'font/base-rtl? s)))
true "שלום עולם" ; Hebrew
true "مرحبا بكم" ; Arabic
true "مرحبا Metabase" ; RTL-first, embedded LTR
false "Metabase مرحبا" ; LTR-first, embedded RTL
true "123 مرحبا" ; digits neutral; first STRONG char is Arabic
false "Hello world"
false ""
false "日本語"))) ; CJK is LTR
(deftest ^:parallel reorder-bidi-items-test
(let [mk (fn [t sp]
{:text t
:space-before? sp})
texts (fn [items]
(mapv :text (#'typeset/reorder-bidi-items items)))]
(testing "a left-to-right line keeps its word order"
(is (= ["Hello" "world"]
(texts [(mk "Hello" false)
(mk "world" true)]))))
(testing "a single item (or empty line) is returned unchanged"
(is (= ["שלום"]
(texts [(mk "שלום" false)])))
(is (= []
(texts []))))
(testing "a right-to-left line has its words reversed to visual order"
;; logical [aleph beth gimel] reads right-to-left, so visually gimel is leftmost
(is (= ["ג" "ב" "א"]
(texts [(mk "א" false)
(mk "ב" true)
(mk "ג" true)]))))
(testing "the first visual word loses its leading space; later words keep an inter-word space"
(is (= [false true true]
(->> [(mk "א" false)
(mk "ב" true)
(mk "ג" true)]
(#'typeset/reorder-bidi-items)
(mapv :space-before?)))))
(testing "an embedded left-to-right word keeps its internal order within an RTL line"
;; logical: shalom, Metabase, olam -> visual L-to-R: olam, Metabase, shalom
(is (= ["עולם" "Metabase" "שלום"]
(texts [(mk "שלום" false)
(mk "Metabase" true)
(mk "עולם" true)]))))))
(deftest ^:parallel effective-display-test
(testing "a non-visualizer card uses its own display"
(is (= :bar (#'pdf/effective-display {:display "bar"} {})))
(is (= :bar (#'pdf/effective-display {:display "bar"} {:visualization_settings {}}))))
(testing "a visualizer dashcard overrides the underlying card's display"
;; e.g. a smartscalar card surfaced as a bar chart -> fill its cell like a real bar chart
(is (= :bar (#'pdf/effective-display
{:display "smartscalar"}
{:visualization_settings {:visualization {:display "bar"}}}))))
(testing "a visualizer dashcard without an explicit display falls back to the card display"
(is (= :line (#'pdf/effective-display
{:display "line"}
{:visualization_settings {:visualization {:settings {}}}})))))
;; --------------------------------------------------------------------------------------------
;; Text-layout characterization (golden) test.
;;
;; Captures the exact sequence of `draw-line!` calls -- {font, size, x, y, text} -- that each text
;; path produces, for a battery of fixtures covering plain wrapping, RTL titles, inline-styled
;; Markdown, headings/lists, CJK wrapping, Arabic/Hebrew reordering, and furigana. The numbers come
;; from PDFBox font metrics + ICU shaping (pure integer math, no rasterization), so they are
;; deterministic across platforms. This pins current layout behaviour so the planned wrap/measure
;; unification can be refactored without silently changing what gets drawn or where. If a change is
;; intentional, regenerate the expected values.
;; --------------------------------------------------------------------------------------------
(defn- round1 [v]
(/ (Math/round (* (double v) 10.0)) 10.0))
(defn- capture-line-draws!
"Run `render!` with `draw-line!` intercepted, returning each drawn line as
{:font <face-id> :pt :x :y :text} with coordinates rounded to 0.1pt."
[render!]
(let [calls (atom [])]
(dynamic-redefs/with-dynamic-fn-redefs [pdf/draw-line! (fn [_cs face font-pt x y text]
(swap! calls conj {:font (:id face)
:pt (round1 font-pt)
:x (round1 x)
:y (round1 y)
:text text}))]
(render!))
@calls))
(deftest ^:synchronized text-layout-characterization-test
(with-open [doc (PDDocument.)]
(let [page (PDPage. PDRectangle/A4)
_ (.addPage doc page)
cs (PDPageContentStream. doc page)]
(binding [font/*fonts* (#'font/load-fonts! doc)
pdf/*link-rects* (atom [])]
(let [reg (#'font/face :regular)
bold (#'font/face :bold)
;; draw-text-block! (plain text path) and draw-markdown-in-cell! (markdown path)
dtb (fn [face pt x y w h text] #(#'pdf/draw-text-block! cs face pt nil x y w h text))
dmic (fn [x y w h text] #(#'pdf/draw-markdown-in-cell! doc cs x y w h text))]
(doseq [[nm render expected]
;; Plain text flows through the same item pipeline as Markdown, and it draws one
;; record per word (not per line).
[["plain wrap (Latin, 2 lines)"
(dtb reg 10.0 40.0 700.0 120.0 60.0 "The quick brown fox jumps over")
[{:font :regular :pt 10.0 :x 40.0 :y 690.0 :text "The"}
{:font :regular :pt 10.0 :x 59.3 :y 690.0 :text "quick"}
{:font :regular :pt 10.0 :x 85.3 :y 690.0 :text "brown"}
{:font :regular :pt 10.0 :x 116.2 :y 690.0 :text "fox"}
{:font :regular :pt 10.0 :x 133.0 :y 690.0 :text "jumps"}
{:font :regular :pt 10.0 :x 40.0 :y 677.0 :text "over"}]]
["plain RTL title (right-aligned, per-word reorder)"
(dtb bold 13.0 40.0 700.0 200.0 40.0 "مرحبا بكم")
[{:font :bold :pt 13.0 :x 187.5 :y 687.0 :text "بكم"}
{:font :bold :pt 13.0 :x 209.3 :y 687.0 :text "مرحبا"}]]
["markdown inline styles (per-run fonts)"
(dmic 40.0 700.0 240.0 80.0 "Hi **bold** *em* `cd` [lk](https://x.com)")
[{:font :regular :pt 10.5 :x 40.0 :y 689.5 :text "Hi"}
{:font :bold :pt 10.5 :x 52.6 :y 689.5 :text "bold"}
{:font :italic :pt 10.5 :x 76.1 :y 689.5 :text "em"}
{:font :mono :pt 10.5 :x 96.8 :y 689.5 :text "cd"}
{:font :regular :pt 10.5 :x 112.1 :y 689.5 :text "lk"}]]
;; A word whose style changes mid-word with no whitespace ("x**Y**z") is ONE indivisible multi-piece
;; word: the pieces draw contiguously in their own fonts and never break across a line.
["markdown cross-style word stays one indivisible word"
(dmic 40.0 700.0 70.0 80.0 "x**Y**z tail end")
[{:font :regular :pt 10.5 :x 40.0 :y 689.5 :text "x"}
{:font :bold :pt 10.5 :x 45.2 :y 689.5 :text "Y"}
{:font :regular :pt 10.5 :x 52.1 :y 689.5 :text "z"}
{:font :regular :pt 10.5 :x 59.5 :y 689.5 :text "tail"}
{:font :regular :pt 10.5 :x 76.2 :y 689.5 :text "end"}]]
["markdown heading + bullet list (markers)"
(dmic 40.0 700.0 240.0 120.0 "## Head\n\n- one\n- two")
[{:font :bold :pt 13.5 :x 40.0 :y 686.5 :text "Head"}
{:font :regular :pt 10.5 :x 40.0 :y 668.0 :text "- "}
{:font :regular :pt 10.5 :x 46.6 :y 668.0 :text "one"}
{:font :regular :pt 10.5 :x 40.0 :y 650.3 :text "- "}
{:font :regular :pt 10.5 :x 46.6 :y 650.3 :text "two"}]]
["CJK wrap (per-character break units + kinsoku)"
(dmic 40.0 700.0 80.0 80.0 "これはテストです、日本語")
[{:font :regular :pt 10.5 :x 40.0 :y 689.5 :text "こ"}
{:font :regular :pt 10.5 :x 50.5 :y 689.5 :text "れ"}
{:font :regular :pt 10.5 :x 61.0 :y 689.5 :text "は"}
{:font :regular :pt 10.5 :x 71.5 :y 689.5 :text "テ"}
{:font :regular :pt 10.5 :x 82.0 :y 689.5 :text "ス"}
{:font :regular :pt 10.5 :x 92.5 :y 689.5 :text "ト"}
{:font :regular :pt 10.5 :x 103.0 :y 689.5 :text "で"}
{:font :regular :pt 10.5 :x 40.0 :y 675.9 :text "す、"}
{:font :regular :pt 10.5 :x 61.0 :y 675.9 :text "日"}
{:font :regular :pt 10.5 :x 71.5 :y 675.9 :text "本"}
{:font :regular :pt 10.5 :x 82.0 :y 675.9 :text "語"}]]
["Arabic markdown (word reorder + right align)"
(dmic 40.0 700.0 240.0 80.0 "مرحبا بكم في ميتابيس")
[{:font :regular :pt 10.5 :x 186.5 :y 689.5 :text "ميتابيس"}
{:font :regular :pt 10.5 :x 225.6 :y 689.5 :text "في"}
{:font :regular :pt 10.5 :x 240.7 :y 689.5 :text "بكم"}
{:font :regular :pt 10.5 :x 257.5 :y 689.5 :text "مرحبا"}]]
["Hebrew markdown (word reorder + right align)"
(dmic 40.0 700.0 240.0 80.0 "שלום עולם מטאבייס")
[{:font :regular :pt 10.5 :x 193.0 :y 689.5 :text "מטאבייס"}
{:font :regular :pt 10.5 :x 233.7 :y 689.5 :text "עולם"}
{:font :regular :pt 10.5 :x 257.7 :y 689.5 :text "שלום"}]]
["furigana (ruby reading drawn above base)"
(dmic 40.0 700.0 240.0 80.0 "{漢字|かんじ}です")
[{:font :regular :pt 10.5 :x 40.0 :y 682.6 :text "漢字"}
{:font :regular :pt 5.8 :x 41.8 :y 694.1 :text "かんじ"}
{:font :regular :pt 10.5 :x 61.0 :y 682.6 :text "で"}
{:font :regular :pt 10.5 :x 71.5 :y 682.6 :text "す"}]]
["markdown hard line break (two trailing spaces)"
(dmic 40.0 700.0 240.0 80.0 "one two \nthree")
[{:font :regular :pt 10.5 :x 40.0 :y 689.5 :text "one"}
{:font :regular :pt 10.5 :x 60.0 :y 689.5 :text "two"}
{:font :regular :pt 10.5 :x 40.0 :y 675.9 :text "three"}]]]]
(is (= expected (capture-line-draws! render))
nm))
(.close cs))))))
(deftest ^:synchronized em-width-memoization-test
(with-open [doc (PDDocument.)]
(binding [font/*fonts* (#'font/load-fonts! doc)]
(let [reg (#'font/face :regular)
inner @#'font/raw-em-width-inner
strs ["Hello world"
"مرحبا بكم"
"これはテスト"
""
"שלום עולם"]
width (fn [s pt] (#'font/text-width reg pt s))]
(testing "text-width is identical whether *em-width* is the default (uncached) or memoized"
(let [uncached (mapv #(width % 11.0) strs)
cached (binding [font/*em-width* (memo/memo inner)]
(mapv #(width % 11.0) strs))]
(is (= uncached cached))))
(testing "each [face string] is measured once and reused across font sizes (the fit-scale win)"
(let [calls (atom 0)
counting (fn [face s]
(swap! calls inc)
(inner face s))]
(binding [font/*em-width* (memo/memo counting)]
(width "Hello world" 10.0)
(width "Hello world" 20.0) ; same face+string, different size -> no recompute
(width "Goodbye now" 10.0))
(is (= 2 @calls)
"two distinct strings -> two inner computations; the sizes share the cache")))
(testing "scaled widths are exactly proportional to font size (ems are size-independent)"
(binding [font/*em-width* (memo/memo inner)]
(is (= (* 2.0 (width "Hello world" 10.0))
(width "Hello world" 20.0)))))
(testing "the memoization cache is introspectable and clearable (clojure.core.memoize tooling)"
(let [m (memo/memo inner)]
(binding [font/*em-width* m]
(width "Hello world" 10.0)
(width "Goodbye now" 10.0))
(is (ccm/memoized? m))
(is (= 2 (count (ccm/snapshot m))))
(ccm/memo-clear! m)
(is (= 0 (count (ccm/snapshot m))))))))))
;; --------------------------------------------------------------------------------------------
;; Tables: restyling the email table hiccup to fit the PDF card frame
;; --------------------------------------------------------------------------------------------
(defn- divider?
"Whether a restyled header cell carries the border-right divider."
[th]
(-> (get-in th [1 :style] "")
(str/includes? "border-right")))
(deftest ^:parallel restyle-table-test
(let [th (fn [label]
[:th {:style "color: #949AAB;"} label])
;; render-table-head splices the header cells into the :tr as a seq, like `for` output.
table [:table {:style "border: 1px solid #F0F0F0; border-radius: 6px; margin: 16px;"}
[:thead [:tr {} (list (th "a") (th "b") (th "c"))]]
[:tbody [:tr {} [:td {:style "x"} "1"]]]]
[_table attrs
[_thead
[_tr _tr-attrs
th-a th-b th-c]]
tbody] (#'pdf/restyle-table table)]
(testing "the <table> fills its frame and drops its own border/radius/margin"
(is (str/includes? (:style attrs) "width:100%"))
(is (str/includes? (:style attrs) "border:none")))
(testing "header cells get a divider, except the last"
(is (divider? th-a))
(is (divider? th-b))
(is (not (divider? th-c))))
(testing "body rows pass through untouched"
(is (= [:tbody [:tr {} [:td {:style "x"} "1"]]]
tbody)))))
(deftest ^:parallel add-header-dividers-test
(testing "spliced seqs are flattened and all header cells but the last get the divider"
(let [[_ _ a b] (#'pdf/add-header-dividers
[:tr {} (list [:th {:style ""} "a"]
[:th {:style ""} "b"])])]
(is (divider? a))
(is (not (divider? b)))))
(testing "a th without an attrs map is left alone"
(let [tr [:tr {}
[:th "bare"]
[:th {:style ""} "last"]]]
(is (= tr (#'pdf/add-header-dividers tr))))))
;; not ^:parallel: exercises the real CSSBox HTML rendering path
(deftest ^:synchronized table-body-png-sizing-test
(let [data {:cols [{:name "n"
:display_name "N"
:base_type :type/Integer}]
:rows (mapv vector (range 50))}
part {:card {}
:dashcard nil
:result {:data data}}
px-w 400
px-h 300
ss (long @#'pdf/table-supersample)
^BufferedImage img (ImageIO/read (io/input-stream (#'pdf/table-body-png nil part px-w px-h)))]
(testing "width fills the cell at the supersampled scale"
(is (<= (* ss (- px-w 8))
(.getWidth img)
(* ss px-w))))
(testing "height is capped to the cell even though 50 rows don't fit"
(is (<= 1 (.getHeight img) (* ss px-h))))))
;; not ^:parallel: exercises the real CSSBox/PDFBox rendering path (font loading + the no-results HTML->PNG asset)
(deftest ^:synchronized no-results-card-render-test
(testing "a card whose query returns no rows renders the centered no-results placeholder, not a failing chart"
(let [data {:cols [{:name "x" :display_name "X" :base_type :type/Integer}
{:name "y" :display_name "Y" :base_type :type/Integer}]
:rows []}
;; :line would normally take the rectangular fill path (which fails on no data); the empty result
;; must short-circuit to the placeholder regardless of display. This is the UXW-4519 regression.
part {:card {:display "line" :name "Number of subscriptions"}
:dashcard nil
:result {:data data}}]
(testing "no rows detect as :empty regardless of the (line) display"
(is (= :empty (render.card/detect-pulse-chart-type (:card part) (:dashcard part) data))))
(testing "the shared no-results sail-boat asset is present on the classpath"
(is (pos? (alength ^bytes @@#'pdf/no-results-image-bytes))))
(testing "render-card-cell! draws it without throwing, producing a saveable PDF"
(with-open [doc (PDDocument.)]
(binding [font/*fonts* (#'font/load-fonts! doc)]
(let [page (PDPage. PDRectangle/A4)]
(.addPage doc page)
(with-open [cs (PDPageContentStream. doc page)]
(#'pdf/render-card-cell! doc cs nil part 36.0 760.0 480.0 360.0))
(let [baos (ByteArrayOutputStream.)]
(.save doc baos)
(is (pos? (count (.toByteArray baos))))))))))))

View File

@@ -51,11 +51,13 @@
(#'png/render-to-png width)))
(defn- render-with-wrapping
[content width]
(-> {:content content
:attachments {}}
(png/render-html-to-png width)
bytes->image))
([content width]
(render-with-wrapping content width nil))
([content width options]
(-> {:content content
:attachments {}}
(png/render-html-to-png width options)
bytes->image)))
(deftest wrap-non-lato-characters-test
(testing "HTML Content inside tables with characters not supported by the Lato font are wrapped in a span."
@@ -83,3 +85,11 @@
;; We assert that the working render is wider based on the assumption (verified manually by
;; actually looking at the rendered images) that the correctly rendered glyphs are wider.
(is (< (.getWidth broken-render) (.getWidth working-render))))))
(deftest render-html-to-png-scale-test
(testing "the :channel.render/scale option supersamples the raster without changing layout"
(let [content [:div {:style "width: 100px; height: 40px;"} "hello"]
^BufferedImage png1x (render-with-wrapping content 200)
^BufferedImage png2x (render-with-wrapping content 200 {:channel.render/scale 2.0})]
(is (= (* 2 (.getWidth png1x)) (.getWidth png2x)))
(is (= (* 2 (.getHeight png1x)) (.getHeight png2x))))))

View File

@@ -179,6 +179,42 @@
:id "DDDDDDDDD-EEEEEEEEE"}
(slack/upload-file! image-bytes filename))))))))
(deftest upload-file-to-channel!-test
(testing "upload-file-to-channel! shares the file into the given channel via channel_id"
(let [file-bytes (.getBytes "fake-pdf")
filename "dashboard.pdf"
upload-url "https://files.slack.com/upload/v1/CwABAAAAWgoAAZnBg"
complete-req (atom nil)
join-req (atom nil)
fake-routes {#"^https://slack.com/api/files\.getUploadURLExternal.*"
(fn [_] (mock-200-response {:ok true
:upload_url upload-url
:file_id "DDDDDDDDD-EEEEEEEEE"}))
upload-url
(fn [_] (mock-200-response "OK"))
#"^https://slack.com/api/files\.completeUploadExternal.*"
(fn [req]
(reset! complete-req req)
(mock-200-response (slurp "./test_resources/slack_upload_file_response.json")))}]
(http-fake/with-fake-routes
(assoc fake-routes
#"^https://slack.com/api/conversations\.join.*"
(fn [req]
(reset! join-req req)
(mock-200-response (slurp "./test_resources/slack_conversations_join_response.json"))))
(mt/with-temporary-setting-values [slack-app-token "test-token"]
(is (= "https://files.slack.com/files-pri/DDDDDDDDD-EEEEEEEEE/wow.gif"
(slack/upload-file-to-channel! file-bytes filename "C0CHANNEL" "*Aviary KPIs*")))
(testing "joins the channel first (file sharing requires membership)"
(is (= "C0CHANNEL"
(:channel (parse-query-string (:query-string @join-req))))))
(testing "shares the file into the channel with its caption as the message comment"
(let [params (parse-query-string (:query-string @complete-req))]
(is (= "C0CHANNEL" (:channel_id params)))
(is (= "*Aviary KPIs*" (:initial_comment params))))))))))
(deftest post-chat-message!-test
(testing "post-chat-message!"
(http-fake/with-fake-routes {#"^https://slack.com/api/chat\.postMessage.*" (fn [_]

View File

@@ -12,6 +12,7 @@
[metabase.analytics.snowplow-test :as snowplow-test]
[metabase.api.response :as api.response]
[metabase.api.test-util :as api.test-util]
[metabase.channel.render.core :as channel.render]
[metabase.collections.models.collection :as collection]
[metabase.config.core :as config]
[metabase.dashboards-rest.api :as api.dashboard]
@@ -678,6 +679,67 @@
(is (= "You don't have permissions to do that."
(mt/user-http-request :rasta :get 403 (format "dashboard/%d" dashboard-id)))))))))
;;; ------------------------------------------- PDF export ----------------------------------------------------------
;; The actual server-side render is exercised by metabase.channel.render.pdf-test (golden + smoke tests); here we
;; stub it so these tests cover only the HTTP contract: parameter threading, paper size, and content negotiation.
(deftest dashboard-pdf-test
(testing "POST /api/dashboard/:id/pdf"
(mt/with-temp [:model/Dashboard {dash-id :id} {:name "My Bird Dashboard"}]
(let [calls (atom [])
fake-pdf (.getBytes "%PDF-1.4 fake")]
(with-redefs [channel.render/render-dashboard-to-pdf
(fn [dashboard-id user-id parameters paper-key]
(swap! calls conj {:dashboard-id dashboard-id
:user-id user-id
:parameters parameters
:paper-key paper-key})
fake-pdf)]
(testing "defaults to empty parameters and A4, streaming a non-empty PDF body"
(reset! calls [])
(let [resp (mt/user-http-request :rasta :post 200 (format "dashboard/%d/pdf" dash-id)
{:request-options {:as :byte-array}}
{})]
(is (pos? (count resp)))
(is (= [{:dashboard-id dash-id
:user-id (mt/user->id :rasta)
:parameters []
:paper-key :a4}]
@calls))))
(testing "threads parameter overrides (array form) and paper_size through to the renderer"
(reset! calls [])
(mt/user-http-request :rasta :post 200 (format "dashboard/%d/pdf" dash-id)
{:request-options {:as :byte-array}}
{:parameters [{:id "p1" :value "CA"}]
:paper_size "letter"})
(is (= [{:id "p1" :value "CA"}] (:parameters (first @calls))))
(is (= :letter (:paper-key (first @calls)))))
(testing "accepts parameters as a JSON-encoded string (for <form>-driven downloads)"
(reset! calls [])
(mt/user-http-request :rasta :post 200 (format "dashboard/%d/pdf" dash-id)
{:request-options {:as :byte-array}}
{:parameters (json/encode [{:id "p1" :value "NY"}])})
(is (= [{:id "p1" :value "NY"}] (:parameters (first @calls)))))
(testing "a JSON string that is valid JSON but not a well-formed parameter list is a 400 (not a 500)"
;; an object instead of an array
(is (mt/user-http-request :rasta :post 400 (format "dashboard/%d/pdf" dash-id)
{:parameters (json/encode {:id "p1" :value "x"})}))
;; an array whose entry is missing the required :id
(is (mt/user-http-request :rasta :post 400 (format "dashboard/%d/pdf" dash-id)
{:parameters (json/encode [{:value "x"}])})))
(testing "rejects an invalid paper_size"
(mt/user-http-request :rasta :post 400 (format "dashboard/%d/pdf" dash-id)
{:paper_size "a3"})))))))
(deftest dashboard-pdf-permissions-test
(testing "POST /api/dashboard/:id/pdf requires read permission on the dashboard"
(with-redefs [channel.render/render-dashboard-to-pdf (fn [& _] (.getBytes "x"))]
(mt/with-non-admin-groups-no-root-collection-perms
(mt/with-temp [:model/Collection {coll-id :id} {:name "No-read Collection"}
:model/Dashboard {dash-id :id} {:collection_id coll-id}]
(is (= "You don't have permissions to do that."
(mt/user-http-request :rasta :post 403 (format "dashboard/%d/pdf" dash-id) {}))))))))
(deftest put-dashboard-hides-unreadable-cards-test
(testing "PUT /api/dashboard/:id should hide card details from collections the user cannot access (#UXW-3571)"
(mt/with-non-admin-groups-no-root-collection-perms

View File

@@ -8,6 +8,7 @@
[metabase.channel.email.result-attachment :as email.result-attachment]
[metabase.channel.impl.slack :as channel.slack]
[metabase.channel.render.body :as body]
[metabase.channel.render.core :as channel.render]
[metabase.channel.shared :as channel.shared]
[metabase.notification.payload.execute :as notification.payload.execute]
[metabase.notification.payload.temp-storage :as notification.temp-storage]
@@ -1388,3 +1389,122 @@
(first (:channel/email pulse-results))
#"Aviary KPIs"
#"Dashboard content available in attached files"))))))
(deftest dashboard-sub-include-pdf-test
(testing "A channel with :include_pdf attaches a server-rendered PDF of the whole dashboard (#_subs)"
(let [render-args (atom nil)]
;; Stub the renderer: avoid producing a real PDF, and capture the args it's called with.
(with-redefs [channel.render/render-dashboard-to-pdf
(fn [dashboard-id user-id parameters]
(reset! render-args {:dashboard-id dashboard-id
:user-id user-id
:parameters parameters})
(.getBytes "%PDF-1.4 stub" "UTF-8"))]
(mt/with-temp [:model/Card {card-id :id} {:name pulse.test-util/card-name
:dataset_query (mt/mbql-query orders {:limit 1})}
:model/Dashboard {dashboard-id :id} {:name "Aviary KPIs"}
:model/DashboardCard _ {:dashboard_id dashboard-id
:card_id card-id}
:model/Pulse {pulse-id :id} {:name "Pulse Name"
:dashboard_id dashboard-id}
:model/PulseCard _ {:pulse_id pulse-id
:card_id card-id
:position 0}
:model/PulseChannel {pc-id :id} {:pulse_id pulse-id
:channel_type "email"
:details {:include_pdf true}}
:model/PulseChannelRecipient _ {:user_id (pulse.test-util/rasta-id)
:pulse_channel_id pc-id}]
(let [pulse-results (pulse.test-util/with-captured-channel-send-messages!
(pulse.send/send-pulse! (t2/select-one :model/Pulse pulse-id)))]
(testing "renderer is called with the subscription's dashboard id and resolved parameters"
(is (= dashboard-id (:dashboard-id @render-args)))
(is (= [] (:parameters @render-args))))
(let [message (:message (first (:channel/email pulse-results)))
pdf-part (some #(when (= "application/pdf" (:content-type %)) %) message)]
(testing "the PDF attachment is named after the dashboard"
(is (= "Aviary KPIs.pdf" (:file-name pdf-part)))
(is (= java.net.URL (class (:content pdf-part)))))
(testing "the email still includes its HTML body and the chart image"
(is (some #(= "image/png" (:content-type %)) message))
(is (some #(= "text/html; charset=utf-8" (:type %)) message))))))))))
(deftest dashboard-sub-no-pdf-by-default-test
(testing "Without :include_pdf, the renderer is not invoked and no PDF is attached"
(let [called? (atom false)]
(with-redefs [channel.render/render-dashboard-to-pdf
(fn [& _] (reset! called? true) (byte-array 0))]
(mt/with-temp [:model/Card {card-id :id} {:name pulse.test-util/card-name
:dataset_query (mt/mbql-query orders {:limit 1})}
:model/Dashboard {dashboard-id :id} {:name "Aviary KPIs"}
:model/DashboardCard _ {:dashboard_id dashboard-id
:card_id card-id}
:model/Pulse {pulse-id :id} {:name "Pulse Name"
:dashboard_id dashboard-id}
:model/PulseCard _ {:pulse_id pulse-id
:card_id card-id
:position 0}
:model/PulseChannel {pc-id :id} {:pulse_id pulse-id
:channel_type "email"}
:model/PulseChannelRecipient _ {:user_id (pulse.test-util/rasta-id)
:pulse_channel_id pc-id}]
(let [pulse-results (pulse.test-util/with-captured-channel-send-messages!
(pulse.send/send-pulse! (t2/select-one :model/Pulse pulse-id)))]
(is (false? @called?))
(is (= (rasta-dashsub-message
{:message [{"Aviary KPIs" true}
pulse.test-util/png-attachment]})
(mt/summarize-multipart-single-email
(first (:channel/email pulse-results))
#"Aviary KPIs")))))))))
(deftest dashboard-sub-slack-include-pdf-test
(testing "A Slack channel with :include_pdf renders the dashboard PDF and carries it on the message"
(notification.tu/with-channel-fixtures [:channel/slack]
(mt/with-temp [:model/Card {card-id :id} {:name pulse.test-util/card-name
:display :line
:dataset_query (mt/mbql-query orders {:limit 1})}
:model/Dashboard {dashboard-id :id} {:name "Aviary KPIs"}
:model/DashboardCard _ {:dashboard_id dashboard-id :card_id card-id :row 0}
:model/Pulse {pulse-id :id} {:name "Pulse Name" :dashboard_id dashboard-id}
:model/PulseCard _ {:pulse_id pulse-id :card_id card-id :position 0}
:model/PulseChannel _ {:pulse_id pulse-id
:channel_type "slack"
:details {:channel "#general" :include_pdf true}}]
(let [render-args (atom nil)]
(with-redefs [channel.render/render-dashboard-to-pdf
(fn [dashboard-id user-id parameters]
(reset! render-args {:dashboard-id dashboard-id :user-id user-id :parameters parameters})
(.getBytes "%PDF-1.4 stub" "UTF-8"))]
(pulse.test-util/slack-test-setup!
(let [results (pulse.test-util/with-captured-channel-send-messages!
(pulse.send/send-pulse! (t2/select-one :model/Pulse pulse-id)))
msg (first (:channel/slack results))]
(testing "renderer is called with the subscription's dashboard id"
(is (= dashboard-id (:dashboard-id @render-args))))
(testing "the slack message carries the rendered PDF"
(is (bytes? (-> msg :pdf :bytes)))
(is (str/ends-with? (-> msg :pdf :filename) ".pdf")))
(testing "it's a single message: no chart-image blocks, title/link ride along as the PDF caption"
(is (empty? (:blocks msg)))
(is (str/includes? (-> msg :pdf :comment) "Aviary KPIs")))))))))))
(deftest dashboard-sub-slack-no-pdf-sends-images-test
(testing "Without :include_pdf, Slack still sends chart images and no PDF"
(notification.tu/with-channel-fixtures [:channel/slack]
(mt/with-temp [:model/Card {card-id :id} {:name pulse.test-util/card-name
:display :line
:dataset_query (mt/mbql-query orders {:limit 1})}
:model/Dashboard {dashboard-id :id} {:name "Aviary KPIs"}
:model/DashboardCard _ {:dashboard_id dashboard-id :card_id card-id :row 0}
:model/Pulse {pulse-id :id} {:name "Pulse Name" :dashboard_id dashboard-id}
:model/PulseCard _ {:pulse_id pulse-id :card_id card-id :position 0}
:model/PulseChannel _ {:pulse_id pulse-id
:channel_type "slack"
:details {:channel "#general"}}]
(pulse.test-util/slack-test-setup!
(let [results (pulse.test-util/with-captured-channel-send-messages!
(pulse.send/send-pulse! (t2/select-one :model/Pulse pulse-id)))
msg (first (:channel/slack results))]
(is (nil? (:pdf msg)))
(is (some #(= "image" (:type %)) (:blocks msg)))))))))

View File

@@ -1,7 +1,13 @@
(ns metabase.util.http-test
(:require
[clojure.test :refer :all]
[metabase.util.http :as http]))
[metabase.util.http :as http])
(:import
(clojure.lang ExceptionInfo)
(java.io ByteArrayInputStream)
(java.net InetAddress)))
(set! *warn-on-reflection* true)
(deftest valid-host?-test
(testing "external-only strategy (default)"
@@ -25,3 +31,110 @@
(is (true? (http/valid-host? :allow-all "http://localhost")))
(is (true? (http/valid-host? :allow-all "http://192.168.1.1")))
(is (true? (http/valid-host? :allow-all "http://169.254.1.1")))))
;; --------------------------------------------------------------------------------------------
;; SSRF-hardened fetch ([[metabase.util.http/fetch-bytes]] and its helpers). Everything below is
;; intentionally network-free -- the URL/address predicates are pure, the DNS resolver is exercised
;; against `localhost` (resolves to loopback without network IO), and `fetch-bytes` is only checked
;; on URLs that short-circuit at the validation gate before any request is made.
;; --------------------------------------------------------------------------------------------
(def ^:private allowed-urls
["https://example.com/a.png"
"https://sub.example.co.uk/path/to/img.jpg?x=1&y=2"
"https://example.com:8443/a.png" ; non-default https port is fine
"HTTPS://Example.COM/a.png" ; scheme/host are case-insensitive
"https://xn--80ak6aa92e.com/a.png"]) ; punycode IDN host
(def ^:private blocked-urls
["http://example.com/a.png" ; not https
"ftp://example.com/a.png" ; not https
"file:///etc/passwd" ; not https
"javascript:alert(1)" ; not https / malformed
"https://169.254.169.254/latest/meta-data/" ; link-local IP literal (AWS/GCP IMDS)
"https://10.0.0.5/x.png" ; RFC1918 IP literal
"https://192.168.1.1/x.png"
"https://172.16.0.1/x.png"
"https://127.0.0.1/x.png" ; loopback IP literal
"https://[::1]/x.png" ; IPv6 loopback literal
"https://[fe80::1]/x.png" ; IPv6 link-local literal
"https://2130706433/x.png" ; decimal form of 127.0.0.1
"https://0177.0.0.1/x.png" ; octal-ish IP form
"https://localhost/x.png" ; localhost
"https://LOCALHOST/x.png"
"https://foo.localhost/x.png" ; .localhost suffix
"https://svc.internal/x.png" ; .internal suffix
"https://host.local/x.png" ; .local suffix
"https://box.lan/x.png" ; .lan suffix
"https://metadata.google.internal/x.png" ; GCP metadata host
"https://metadata/x.png"
"https://user:pass@example.com/x.png" ; userinfo (credential smuggling)
"https:///x.png" ; no host
"not a url"
""])
(deftest ^:parallel safe-url?-test
(testing "allowed URLs"
(doseq [url allowed-urls]
(is (true? (boolean (http/safe-url? url))) (str "should be allowed: " url))))
(testing "blocked URLs (SSRF / non-https / bad host)"
(doseq [url blocked-urls]
(is (false? (boolean (http/safe-url? url))) (str "should be blocked: " url)))))
(def ^:private public-ips
["8.8.8.8"
"1.1.1.1"
"93.184.216.34"
"100.63.255.255" ; one below the CGNAT 100.64.0.0/10 range
"100.128.0.0" ; one above the CGNAT range
"2606:4700:4700::1111"]) ; public IPv6
(def ^:private non-public-ips
["127.0.0.1" ; loopback
"169.254.169.254" ; link-local (cloud metadata)
"10.1.2.3" ; RFC1918
"172.16.0.1"
"172.31.255.255"
"192.168.0.1"
"0.0.0.0" ; any-local
"224.0.0.1" ; multicast
"100.64.0.1" ; CGNAT
"100.127.255.255" ; CGNAT (top)
"::1" ; IPv6 loopback
"fe80::1" ; IPv6 link-local
"fc00::1" ; IPv6 ULA (fc)
"fd12:3456::1" ; IPv6 ULA (fd)
"ff02::1"]) ; IPv6 multicast
(deftest ^:parallel public-address?-test
(testing "globally-routable addresses are allowed"
(doseq [ip public-ips]
(is (true? (boolean (http/public-address? (InetAddress/getByName ip))))
(str "should be public: " ip))))
(testing "loopback/link-local/private/ULA/CGNAT/multicast addresses are rejected"
(doseq [ip non-public-ips]
(is (false? (boolean (http/public-address? (InetAddress/getByName ip))))
(str "should be rejected: " ip)))))
(deftest ^:parallel ssrf-safe-dns-resolver-test
(testing "the validating resolver throws when a host resolves to a non-public address"
;; `localhost` resolves to loopback (no network needed) -> must be refused
(is (thrown? ExceptionInfo
(.resolve ^org.apache.http.conn.DnsResolver @#'http/ssrf-safe-dns-resolver "localhost")))))
(deftest ^:parallel fetch-bytes-blocks-without-network-test
(testing "blocked URLs return nil at the validation gate, never reaching the network"
(doseq [url ["https://169.254.169.254/latest/meta-data/"
"http://example.com/x.png"
"https://10.0.0.1/x.png"
"https://localhost/x.png"
"https://metadata.google.internal/x.png"]]
(is (nil? (http/fetch-bytes url)) (str "should not fetch: " url)))))
(deftest ^:parallel read-bounded-test
(testing "reads the whole stream when under the cap"
(is (= "hello" (String. ^bytes (#'http/read-bounded (ByteArrayInputStream. (.getBytes "hello")) 100)))))
(testing "reads exactly up to the cap (inclusive)"
(is (= 5 (count (#'http/read-bounded (ByteArrayInputStream. (.getBytes "12345")) 5)))))
(testing "returns nil when the stream exceeds the cap"
(is (nil? (#'http/read-bounded (ByteArrayInputStream. (.getBytes "0123456789")) 5)))))