[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:
committed by
GitHub
parent
09726c60c7
commit
57991c5b67
@@ -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}}
|
||||
|
||||
11
deps.edn
11
deps.edn
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
.SwitchBody {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.SwitchInput {
|
||||
z-index: -1; /* This allows icons with tooltips in the label */
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
.AttachmentSwitchBody {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.AttachmentSwitchInput {
|
||||
z-index: -1;
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export function WaterfallChart({
|
||||
);
|
||||
const option = getWaterfallChartOption(
|
||||
chartModel,
|
||||
WIDTH,
|
||||
width,
|
||||
chartLayout,
|
||||
null,
|
||||
[],
|
||||
|
||||
@@ -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}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
9
frontend/src/metabase/static-viz/lib/utils.ts
Normal file
9
frontend/src/metabase/static-viz/lib/utils.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export const getChartHeight = ({
|
||||
fitWithinBounds,
|
||||
height,
|
||||
legendHeight,
|
||||
}: {
|
||||
fitWithinBounds: boolean;
|
||||
height: number;
|
||||
legendHeight: number;
|
||||
}) => (fitWithinBounds ? Math.max(height - legendHeight, 1) : height);
|
||||
@@ -136,6 +136,9 @@ export interface StaticVisualizationProps {
|
||||
renderingContext: RenderingContext;
|
||||
isStorybook?: boolean;
|
||||
hasDevWatermark?: boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
fitWithinBounds?: boolean;
|
||||
}
|
||||
|
||||
export interface VisualizationProps {
|
||||
|
||||
BIN
resources/fonts/pdf/NotoSans-Bold.ttf
Normal file
BIN
resources/fonts/pdf/NotoSans-Bold.ttf
Normal file
Binary file not shown.
BIN
resources/fonts/pdf/NotoSans-BoldItalic.ttf
Normal file
BIN
resources/fonts/pdf/NotoSans-BoldItalic.ttf
Normal file
Binary file not shown.
BIN
resources/fonts/pdf/NotoSans-Italic.ttf
Normal file
BIN
resources/fonts/pdf/NotoSans-Italic.ttf
Normal file
Binary file not shown.
BIN
resources/fonts/pdf/NotoSans-Regular.ttf
Normal file
BIN
resources/fonts/pdf/NotoSans-Regular.ttf
Normal file
Binary file not shown.
BIN
resources/fonts/pdf/NotoSansArabic-Bold.ttf
Normal file
BIN
resources/fonts/pdf/NotoSansArabic-Bold.ttf
Normal file
Binary file not shown.
BIN
resources/fonts/pdf/NotoSansArabic-Regular.ttf
Normal file
BIN
resources/fonts/pdf/NotoSansArabic-Regular.ttf
Normal file
Binary file not shown.
BIN
resources/fonts/pdf/NotoSansHebrew-Bold.ttf
Normal file
BIN
resources/fonts/pdf/NotoSansHebrew-Bold.ttf
Normal file
Binary file not shown.
BIN
resources/fonts/pdf/NotoSansHebrew-Regular.ttf
Normal file
BIN
resources/fonts/pdf/NotoSansHebrew-Regular.ttf
Normal file
Binary file not shown.
BIN
resources/fonts/pdf/NotoSansJP-Bold.ttf
Normal file
BIN
resources/fonts/pdf/NotoSansJP-Bold.ttf
Normal file
Binary file not shown.
BIN
resources/fonts/pdf/NotoSansJP-Regular.ttf
Normal file
BIN
resources/fonts/pdf/NotoSansJP-Regular.ttf
Normal file
Binary file not shown.
BIN
resources/fonts/pdf/NotoSansKR-Bold.ttf
Normal file
BIN
resources/fonts/pdf/NotoSansKR-Bold.ttf
Normal file
Binary file not shown.
BIN
resources/fonts/pdf/NotoSansKR-Regular.ttf
Normal file
BIN
resources/fonts/pdf/NotoSansKR-Regular.ttf
Normal file
Binary file not shown.
BIN
resources/fonts/pdf/NotoSansMono-Regular.ttf
Normal file
BIN
resources/fonts/pdf/NotoSansMono-Regular.ttf
Normal file
Binary file not shown.
BIN
resources/fonts/pdf/NotoSansSC-Bold.ttf
Normal file
BIN
resources/fonts/pdf/NotoSansSC-Bold.ttf
Normal file
Binary file not shown.
BIN
resources/fonts/pdf/NotoSansSC-Regular.ttf
Normal file
BIN
resources/fonts/pdf/NotoSansSC-Regular.ttf
Normal file
Binary file not shown.
18
resources/fonts/pdf/metabase_logo_with_text.svg
Normal file
18
resources/fonts/pdf/metabase_logo_with_text.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -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>")
|
||||
|
||||
@@ -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)))))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")))))
|
||||
|
||||
1361
src/metabase/channel/render/pdf.clj
Normal file
1361
src/metabase/channel/render/pdf.clj
Normal file
File diff suppressed because it is too large
Load Diff
106
src/metabase/channel/render/pdf/common.clj
Normal file
106
src/metabase/channel/render/pdf/common.clj
Normal 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))
|
||||
324
src/metabase/channel/render/pdf/font.clj
Normal file
324
src/metabase/channel/render/pdf/font.clj
Normal 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))))
|
||||
217
src/metabase/channel/render/pdf/markdown.clj
Normal file
217
src/metabase/channel/render/pdf/markdown.clj
Normal 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.
|
||||
538
src/metabase/channel/render/pdf/typeset.clj
Normal file
538
src/metabase/channel/render/pdf/typeset.clj
Normal 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))))))))
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?])
|
||||
|
||||
@@ -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])]
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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)))))
|
||||
|
||||
35
test/metabase/channel/render/pdf/markdown_test.clj
Normal file
35
test/metabase/channel/render/pdf/markdown_test.clj
Normal 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)))))))
|
||||
730
test/metabase/channel/render/pdf_test.clj
Normal file
730
test/metabase/channel/render/pdf_test.clj
Normal 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))))))))))))
|
||||
@@ -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))))))
|
||||
|
||||
@@ -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 [_]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)))))))))
|
||||
|
||||
@@ -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)))))
|
||||
|
||||
Reference in New Issue
Block a user