출력 보안과 이력
PDF 암호와 복사 금지, 인쇄·저장 직전의 출력 이력 기록, 출력 막기.
보고서 정의 (JSON)
{
"name": "재직증명서",
"page": {
"size": "A4",
"margins": 20
},
"fingerprint": true,
"font": {
"family": "Nanum Gothic",
"size": 10.5
},
"watermark": [
{
"text": "{params.user} · {format(now(), \"yyyy-MM-dd HH:mm\")} 출력",
"repeat": true,
"opacity": 0.07,
"fontSize": 9,
"gap": 34,
"angle": -30
},
{
"text": "원 본",
"opacity": 0.06,
"fontSize": 120,
"color": "#2340f5"
}
],
"emboss": {
"text": "(주)세컨드코리아 대표이사 직인 ·",
"centerText": "인증",
"x": 128,
"y": 232,
"w": 36,
"depth": 0.35
},
"bands": {
"title": {
"height": 240,
"elements": [
{
"type": "text",
"x": 0,
"y": 0,
"w": 85,
"h": 7,
"text": "{docNo}",
"style": {
"fontSize": 9,
"color": "#555"
}
},
{
"type": "text",
"x": 0,
"y": 14,
"w": 170,
"h": 18,
"text": "재 직 증 명 서",
"style": {
"fontSize": 26,
"bold": true,
"align": "center",
"letterSpacing": 1.5
}
},
{
"type": "text",
"x": 0,
"y": 44,
"w": 10,
"h": 33,
"text": "인\n적\n사\n항",
"style": {
"background": "#f6f7f9",
"bold": true,
"align": "center",
"border": "0.25 solid #333",
"lineHeight": 1.5
}
},
{
"type": "text",
"x": 10,
"y": 44,
"w": 30,
"h": 11,
"text": "성 명",
"style": {
"background": "#f6f7f9",
"bold": true,
"align": "center",
"border": "0.25 solid #333"
}
},
{
"type": "text",
"x": 40,
"y": 44,
"w": 50,
"h": 11,
"text": "{person.name}",
"style": {
"border": "0.25 solid #333",
"padding": [
0.5,
2,
0.5,
2
]
}
},
{
"type": "text",
"x": 90,
"y": 44,
"w": 30,
"h": 11,
"text": "생년월일",
"style": {
"background": "#f6f7f9",
"bold": true,
"align": "center",
"border": "0.25 solid #333"
}
},
{
"type": "text",
"x": 120,
"y": 44,
"w": 50,
"h": 11,
"text": "{format(person.birth, \"yyyy년 M월 d일\")}",
"style": {
"border": "0.25 solid #333",
"padding": [
0.5,
2,
0.5,
2
]
}
},
{
"type": "text",
"x": 10,
"y": 55,
"w": 30,
"h": 22,
"text": "주 소",
"style": {
"background": "#f6f7f9",
"bold": true,
"align": "center",
"border": "0.25 solid #333"
}
},
{
"type": "text",
"x": 40,
"y": 55,
"w": 130,
"h": 22,
"text": "{person.address}",
"style": {
"border": "0.25 solid #333",
"padding": [
0.5,
2,
0.5,
2
]
}
},
{
"type": "text",
"x": 0,
"y": 77,
"w": 10,
"h": 33,
"text": "재\n직\n사\n항",
"style": {
"background": "#f6f7f9",
"bold": true,
"align": "center",
"border": "0.25 solid #333",
"lineHeight": 1.5
}
},
{
"type": "text",
"x": 10,
"y": 77,
"w": 30,
"h": 11,
"text": "회 사 명",
"style": {
"background": "#f6f7f9",
"bold": true,
"align": "center",
"border": "0.25 solid #333"
}
},
{
"type": "text",
"x": 40,
"y": 77,
"w": 130,
"h": 11,
"text": "{company.name}",
"style": {
"border": "0.25 solid #333",
"padding": [
0.5,
2,
0.5,
2
]
}
},
{
"type": "text",
"x": 10,
"y": 88,
"w": 30,
"h": 11,
"text": "부 서",
"style": {
"background": "#f6f7f9",
"bold": true,
"align": "center",
"border": "0.25 solid #333"
}
},
{
"type": "text",
"x": 40,
"y": 88,
"w": 50,
"h": 11,
"text": "{person.dept}",
"style": {
"border": "0.25 solid #333",
"padding": [
0.5,
2,
0.5,
2
]
}
},
{
"type": "text",
"x": 90,
"y": 88,
"w": 30,
"h": 11,
"text": "직 위",
"style": {
"background": "#f6f7f9",
"bold": true,
"align": "center",
"border": "0.25 solid #333"
}
},
{
"type": "text",
"x": 120,
"y": 88,
"w": 50,
"h": 11,
"text": "{person.position}",
"style": {
"border": "0.25 solid #333",
"padding": [
0.5,
2,
0.5,
2
]
}
},
{
"type": "text",
"x": 10,
"y": 99,
"w": 30,
"h": 11,
"text": "재직기간",
"style": {
"background": "#f6f7f9",
"bold": true,
"align": "center",
"border": "0.25 solid #333"
}
},
{
"type": "text",
"x": 40,
"y": 99,
"w": 130,
"h": 11,
"text": "{format(person.joined, \"yyyy년 M월 d일\")} ~ 현재 ({floor(dateDiff(person.joined, issued) / 365)}년 {floor((dateDiff(person.joined, issued) % 365) / 30)}개월)",
"style": {
"border": "0.25 solid #333",
"padding": [
0.5,
2,
0.5,
2
]
}
},
{
"type": "text",
"x": 0,
"y": 110,
"w": 40,
"h": 11,
"text": "용 도",
"style": {
"background": "#f6f7f9",
"bold": true,
"align": "center",
"border": "0.25 solid #333"
}
},
{
"type": "text",
"x": 40,
"y": 110,
"w": 130,
"h": 11,
"text": "{purpose}",
"style": {
"border": "0.25 solid #333",
"padding": [
0.5,
2,
0.5,
2
]
}
},
{
"type": "text",
"x": 0,
"y": 136,
"w": 170,
"h": 10,
"text": "위와 같이 재직하고 있음을 증명합니다.",
"style": {
"align": "center",
"fontSize": 13
}
},
{
"type": "text",
"x": 0,
"y": 160,
"w": 170,
"h": 10,
"text": "{format(issued, \"yyyy년 M월 d일\")}",
"style": {
"align": "center",
"fontSize": 12
}
},
{
"type": "text",
"x": 0,
"y": 190,
"w": 170,
"h": 12,
"text": "{company.name} 대표이사 {company.ceo}",
"style": {
"align": "center",
"fontSize": 15,
"bold": true,
"letterSpacing": 0.6
}
},
{
"type": "image",
"x": 131,
"y": 184,
"w": 22,
"h": 22,
"src": "data:image/svg+xml;base64,… (1,866 chars)"
},
{
"type": "text",
"x": 0,
"y": 203,
"w": 170,
"h": 7,
"text": "{company.address}",
"style": {
"align": "center",
"fontSize": 9,
"color": "#555"
}
},
{
"type": "barcode",
"x": 0,
"y": 226,
"w": 22,
"h": 22,
"expr": "\"https://freereport.app/verify/\" + replace(docNo, \" \", \"\") + \"?h=\" + docHashShort",
"format": "qr",
"ecc": "Q"
},
{
"type": "text",
"x": 24,
"y": 230,
"w": 90,
"h": 14,
"text": "이 증명서는 위·변조 방지 처리가 되어 있습니다.\nQR 코드로 원본 여부를 확인할 수 있습니다.\n확인코드 {docHashShort}",
"style": {
"fontSize": 7.5,
"color": "#555",
"valign": "top"
}
}
]
}
},
"params": {
"user": {
"label": "출력자",
"type": "string",
"default": "김민준 (개발팀)"
}
}
}데이터
긴 목록은 처음 3건만 보여 줍니다.
{
"docNo": "제 2026-0412 호",
"person": {
"name": "홍길동",
"birth": "1990-03-15",
"address": "서울특별시 중구 세종대로 110",
"dept": "개발팀",
"position": "책임연구원",
"joined": "2019-03-04"
},
"purpose": "금융기관 제출용",
"company": {
"name": "(주)세컨드코리아",
"ceo": "구명석",
"address": "경기도 파주시 와석순환로 507, 701호"
},
"issued": "2026-10-15"
}이 데모의 코드
import type { ReportDefinition, Viewer } from '@freereportjs/core';
import { certificateData } from '../../samples/data';
import { certificateReport } from '../../samples/reports';
import { mountViewer, outputLine, textInput, toggle, tr, type DemoContext } from '../ui';
/**
* Output control: the watermark names who printed the page and when, onOutput writes an output log
* (or cancels the output), and the PDF gets an open password and copy/print permissions.
*/
const base = certificateReport();
export const definition: ReportDefinition = {
...base,
params: { user: { label: '출력자', type: 'string', default: '김민준 (개발팀)' } },
watermark: [
{ text: '{params.user} · {format(now(), "yyyy-MM-dd HH:mm")} 출력', repeat: true, opacity: 0.07, fontSize: 9, gap: 34, angle: -30 },
{ text: '원 본', opacity: 0.06, fontSize: 120, color: '#2340f5' },
],
};
export const sampleData = certificateData;
export default function mount(ctx: DemoContext) {
const state = { user: '김민준 (개발팀)', password: '', noCopy: true, noPrint: false, block: false };
let viewer: Viewer | null = null;
// pdfSecurity is a report option, so the viewer is rebuilt when it changes
const open = () => {
viewer?.destroy();
viewer = mountViewer(ctx, {
definition,
data: sampleData,
params: { user: state.user },
zoom: 'page',
fileName: '재직증명서',
pdfSecurity: { userPassword: state.password || undefined, permissions: { copy: !state.noCopy, print: !state.noPrint } },
onOutput: e => {
// a real system would POST this to its output log (개인정보 출력 이력) before allowing it
ctx.log(`${outputLine(ctx.lang, e)} · ${tr(ctx.lang, { en: 'user', ko: '출력자' })} ${String(e.params.user ?? '')}${state.block ? tr(ctx.lang, { en: ' → blocked', ko: ' → 차단' }) : ''}`);
return !state.block;
},
});
};
textInput(ctx, tr(ctx.lang, { en: 'User', ko: '출력자' }), state.user, v => { state.user = v; void viewer?.setParams({ user: v }); });
textInput(ctx, tr(ctx.lang, { en: 'PDF password', ko: 'PDF 열기 암호' }), '', v => { state.password = v; open(); });
toggle(ctx, tr(ctx.lang, { en: 'PDF: no copying', ko: 'PDF 복사 금지' }), state.noCopy, on => { state.noCopy = on; open(); });
toggle(ctx, tr(ctx.lang, { en: 'PDF: no printing', ko: 'PDF 인쇄 금지' }), state.noPrint, on => { state.noPrint = on; open(); });
toggle(ctx, tr(ctx.lang, { en: 'Block outputs', ko: '출력 막기' }), state.block, on => { state.block = on; });
open();
}