You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by wu...@apache.org on 2026/10/20 11:02:54 UTC

(skywalking-website) 01/01: Add the Certificate of Contribution

This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch feat/certificate
in repository https://gitbox.apache.org/repos/asf/skywalking-website.git

commit daded30c94898e9d46f252078c915f6e97d46bd3
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Tue Oct 20 19:02:42 2026 +0800

    Add the Certificate of Contribution
    
    Every PMC member, committer and contributor listed on the site can get an
    Apache SkyWalking certificate at /certificate/. It is drawn in the browser
    from data the build publishes, downloads as PNG and prints on one A4 page.
    
    - data/repositories.yml catalogs every counted repository into eight
      contribution categories; validation fails while a repository is missing,
      duplicated or stale. The certificate's contribution polygon has one vertex
      per category, placed by the person's rank in it in 10% bands.
    - scripts/certificate.js builds the page data and each day's list of issued
      certificate codes (certificate/verify/<date>.txt). A certificate ID is the
      date plus an 80-bit SHA-256 code over its facts; its QR code carries both,
      and the page verifies them against that day's list.
    - The deploy carries certificate/verify/ forward on asf-site, and only that
      folder; deploying runs share one concurrency group.
    - Team page certificate badge, a certificate button on contributor pages, and
      a full-width contributors page header.
---
 .github/workflows/deploy.yaml                      |   27 +
 .gitignore                                         |    3 +
 CLAUDE.md                                          |   33 +
 README.md                                          |   12 +
 assets/js/certificate/designs/claude.js            |  425 ++++
 assets/js/certificate/model.js                     |  148 ++
 assets/js/certificate/page.js                      |  293 +++
 assets/js/certificate/runtime.js                   |  154 ++
 assets/scss/_custom_certificate.scss               |  105 +
 assets/scss/_custom_contributors.scss              |    6 +
 assets/scss/_custom_team.scss                      |   23 +
 content/certificate/_index.md                      |    7 +
 data/repositories.yml                              |   92 +
 layouts/certificate/baseof.html                    |   23 +
 layouts/contributors/list.html                     |    4 +
 layouts/shortcodes/certificate.html                |   60 +
 layouts/shortcodes/contributors-search.html        |    1 +
 layouts/team/list.html                             |   34 +-
 package.json                                       |   10 +-
 scripts/certificate.js                             |  104 +
 scripts/certificate.test.js                        |   96 +
 scripts/project-config.js                          |   36 +-
 scripts/project-config.test.js                     |   25 +-
 .../certificate/asf-logo-horizontal-color.svg      |   43 +
 themes/docsy/assets/scss/_styles_project.scss      |    1 +
 themes/docsy/layouts/partials/script-search.html   |    1 +
 themes/docsy/static/js/qrcode-generator-1.4.4.js   | 2297 ++++++++++++++++++++
 27 files changed, 4048 insertions(+), 15 deletions(-)

diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml
index 87c484f18f28..7ce2ae39ac08 100644
--- a/.github/workflows/deploy.yaml
+++ b/.github/workflows/deploy.yaml
@@ -27,6 +27,12 @@ on:
 jobs:
   build:
     runs-on: ubuntu-latest
+    # A deploy replaces the whole asf-site tree, carrying certificate/verify/ forward from the
+    # tree it fetched. Two deploys at once could drop each other's certificate records, so
+    # deploying runs queue in one group; pull request builds keep their own groups.
+    concurrency:
+      group: ${{ github.event_name == 'pull_request' && format('pr-{0}', github.ref) || 'asf-site-deploy' }}
+      cancel-in-progress: false
     permissions:
       contents: write
     env:
@@ -48,6 +54,27 @@ jobs:
           # from 60/hr (anonymous) to 1,000/hr.
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
 
+      # The deploy below replaces the whole asf-site tree. The daily lists of issued certificate
+      # codes (certificate/verify/<date>.txt) are what keeps a certificate verifiable, so this
+      # one folder is carried forward from asf-site and merged with today's list. Nothing else
+      # is kept. Only that folder's files are downloaded (blobless fetch); any failure stops the
+      # deploy rather than publishing without the records.
+      - name: Keep issued certificate records
+        if: (github.event_name == 'push' && github.ref == 'refs/heads/master') || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
+        run: |
+          git fetch --depth=1 --filter=blob:none origin asf-site
+          if git cat-file -e FETCH_HEAD:certificate/verify 2>/dev/null; then
+            kept=$(mktemp -d)
+            git --work-tree="$kept" restore --source=FETCH_HEAD --worktree -- certificate/verify
+            mkdir -p public/certificate/verify
+            for file in "$kept"/certificate/verify/*; do
+              target="public/certificate/verify/$(basename "$file")"
+              # Byte order keeps the '#' category header first and merges identical headers.
+              if [ -f "$target" ]; then LC_ALL=C sort -u "$file" "$target" -o "$target"; else cp "$file" "$target"; fi
+            done
+            echo "Kept $(ls "$kept"/certificate/verify | wc -l) daily certificate records."
+          fi
+
       - name: Deploy
         uses: peaceiris/actions-gh-pages@v4
         if: (github.event_name == 'push' && github.ref == 'refs/heads/master') || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
diff --git a/.gitignore b/.gitignore
index 97ea3db4423e..311652f69860 100755
--- a/.gitignore
+++ b/.gitignore
@@ -29,6 +29,9 @@
 /tmp
 /data/docSidebar/*
 /data/team.yml
+# Certificate data, built by scripts/certificate.js
+/assets/certificate/
+/static/certificate/verify/
 /themes/docsy/static/js/mergedData.js
 !.gitkeep
 /content/docs/*
diff --git a/CLAUDE.md b/CLAUDE.md
index b74411401c50..806dd31160e1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -44,6 +44,39 @@ from `data/projects.yml`. Its `showcase` block contains Showcase metadata,
 See the complete YAML example in README.md. Run `node scripts/project-config.js`
 for local configuration validation without fetching repositories or artifacts.
 
+`data/repositories.yml` catalogs every repository whose contributors the site
+counts — each repository of `data/projects.yml`, the Showcase and the Website —
+into the contribution categories of the Certificate of Contribution
+(`/certificate/`). **When a repository is added to the site, add it as
+`owner/repo` to exactly one category there in the same change.** The validator
+rejects missing, duplicate and stale entries, and it runs inside every build.
+Category order is the vertex order of the certificate's contribution polygon.
+
+### Certificate of Contribution data
+
+`scripts/certificate.js` runs in every build: after `npm run team` in
+`build-with-docs` (the deploy) and `build-preview`; `npm run build` has no
+contributor data, so its certificates list members only. Locally, run
+`npm run team` (needs `GITHUB_TOKEN`) and then `npm run certificate` before
+`hugo server`. It writes two gitignored files:
+
+- `assets/certificate/people.json`: today's data the `/certificate/` page draws
+  from. It is regenerated every build and never kept.
+- `static/certificate/verify/<yyyy-mm-dd>.txt`: a `#` header naming that day's
+  categories in polygon order, then the code of every certificate that can be
+  issued that day. A certificate's verify link carries its date, code and facts;
+  the page recomputes the code, looks it up in that day's list, and labels the
+  polygon with that day's categories.
+
+`asf-site` is replaced on every deploy, except `certificate/verify/`: the
+"Keep issued certificate records" step in `.github/workflows/deploy.yaml`
+carries that one folder forward and merges same-day lists; deploying runs share one
+concurrency group so they cannot drop each other's records. **Never delete or
+rename it, and never carry any other folder forward.** The code format lives in
+`assets/js/certificate/model.js` (`canonical`, `encode`); changing it breaks
+every issued certificate, so a new format needs a new `VERSION` while `v1`
+links keep verifying.
+
 ## Releases in data/projects.yml
 
 Each release has `version` and a boolean `latest`. Keep exactly one `latest: true`
diff --git a/README.md b/README.md
index 6a4e34fdaefb..287a078f4e83 100755
--- a/README.md
+++ b/README.md
@@ -82,6 +82,18 @@ available. Each project's `dockerImages` array sits alongside `releases` and
 contains named Docker Hub repository links with optional descriptions. Images
 remain available independently of the selected release; they are repository
 links rather than version-pinned image tags.
+`data/repositories.yml` catalogs every repository whose contributors the site counts
+(each repository of `data/projects.yml`, the Showcase and the Website) into the
+contribution categories of the Certificate of Contribution at `/certificate/`. Add a
+new repository there, as `owner/repo` under one category, in the same change that adds
+it to the site; validation fails while any repository is missing, duplicated or stale.
+`scripts/certificate.js` (part of every build) builds the certificate page's data and
+the day's list of issued certificate codes, `certificate/verify/<date>.txt`. It reads
+the contributor data that `npm run team` fetches, so `build-with-docs` and
+`build-preview` run it after `npm run team`; `npm run build` has no contributor data
+and lists members only. The deploy keeps `certificate/verify/` on `asf-site` across
+deploys, so issued certificates stay verifiable; everything else is replaced. Locally,
+run `npm run team` (with `GITHUB_TOKEN`) and `npm run certificate` before `hugo server`.
 Run `node scripts/project-config.js` to validate the configuration locally without
 fetching documentation or release artifacts.
 
diff --git a/assets/js/certificate/designs/claude.js b/assets/js/certificate/designs/claude.js
new file mode 100644
index 000000000000..32deba98fae5
--- /dev/null
+++ b/assets/js/certificate/designs/claude.js
@@ -0,0 +1,425 @@
+/*
+ * "Contribution Sky Map" — Apache SkyWalking certificate design.
+ *
+ * Left: a light certificate in the website's hero wash. Right: a night-sky panel with the
+ * recipient's contribution polygon over the repository categories of data/repositories.yml.
+ */
+(function () {
+  'use strict';
+
+  window.SWCert = window.SWCert || {};
+  SWCert.designs = SWCert.designs || {};
+
+  var W = 1123, H = 794;
+  var INK = '#172b40', BODY = '#4b5563', MUTED = '#8a93a3', LINE = '#e6e9ee', BRAND = '#3788D0';
+  var LEFT = 56, CONTENT_W = 600;
+  var PANEL_X = 700, PANEL_W = W - 16 - PANEL_X;          // 407
+  var CX = PANEL_X + PANEL_W / 2, CY = 286, R = 98;       // contribution polygon centre and radius
+
+  var TIERS = {
+    'PMC Member': { from: '#28265E', to: '#7C297D', solid: '#7C297D', glow: '#d9a8f0' },
+    'Committer': { from: '#2875b9', to: '#479EEB', solid: '#2875b9', glow: '#9cc9f5' },
+    'Contributor': { from: '#DD552C', to: '#F79A23', solid: '#DD552C', glow: '#ffd29a' }
+  };
+  var TRADEMARK = 'Apache SkyWalking, SkyWalking, Apache, the Apache feather logo, and the Apache ' +
+    'SkyWalking project logo are either registered trademarks or trademarks of the Apache Software Foundation.';
+
+  function num(n) { return Number(n).toLocaleString('en-US'); }
+  function f(n) { return Math.round(n * 100) / 100; }
+
+  function hash(s) {                                       // FNV-1a → [0, 1)
+    var h = 2166136261;
+    for (var i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
+    return ((h >>> 0) % 100000) / 100000;
+  }
+
+  function wrap(text, font, maxWidth, ctx) {
+    var words = text.split(' '), lines = [], line = '';
+    words.forEach(function (w) {
+      var next = line ? line + ' ' + w : w;
+      if (line && ctx.measure(next, font) > maxWidth) { lines.push(line); line = w; } else { line = next; }
+    });
+    if (line) lines.push(line);
+    return lines;
+  }
+
+  function textLines(lines, x, y, lh, attrs, ctx) {
+    return '<text x="' + x + '" y="' + y + '" ' + attrs + '>' + lines.map(function (l, i) {
+      return '<tspan x="' + x + '" dy="' + (i ? lh : 0) + '">' + ctx.escape(l) + '</tspan>';
+    }).join('') + '</text>';
+  }
+
+  function statement(p) {
+    if (p.role === 'PMC Member') {
+      return 'in recognition of stewardship as a member of the Apache SkyWalking Project Management ' +
+        'Committee, and of contributions to Apache SkyWalking, an open source observability platform ' +
+        'of The Apache Software Foundation.';
+    }
+    if (p.role === 'Committer') {
+      return 'in recognition of earning commit access to Apache SkyWalking, the open source ' +
+        'observability platform of The Apache Software Foundation.';
+    }
+    return 'in recognition of contributions to Apache SkyWalking, the open source observability ' +
+      'platform of The Apache Software Foundation.';
+  }
+
+  // Contribution polygon: one vertex per category of data/repositories.yml (ctx.catalogs, in
+  // config order), clockwise from the top. A vertex's distance is the recipient's rank among
+  // everyone who contributed to that category, in ten 10% bands: the top 10% reach the outer
+  // ring, the next 10% the ninth ring, and so on; categories without contributions sit at the
+  // centre.
+  function band(c) {
+    if (c.band != null) return c.band;                     // computed by SWCertModel
+    return c.contributions ? 10 - Math.floor((c.rank - 1) * 10 / c.of) : 0;
+  }
+
+  function polygon(p, ctx, tier, namesOnly) {
+    var cats = ctx.catalogs, n = cats.length, out = [];
+    var mineBy = {};
+    (p.categories || []).forEach(function (c) { mineBy[c.id] = c; });
+    var stat = cats.map(function (c) { return mineBy[c.id] || { contributions: 0, rank: null, of: 0 }; });
+    var any = stat.some(function (c) { return c.contributions > 0; });
+    function angle(k) { return -Math.PI / 2 + k * 2 * Math.PI / n; }
+    function at(k, r) { return [CX + Math.cos(angle(k)) * r, CY + Math.sin(angle(k)) * r]; }
+    function level(k) { return band(stat[k]) / 10; }
+    function ring(k) {
+      return cats.map(function (c, i) { var q = at(i, R * k); return f(q[0]) + ',' + f(q[1]); }).join(' ');
+    }
+
+    // Frame: one ring per 10% band, the halfway ring a little stronger, and the axes.
+    for (var b = 1; b < 10; b++) {
+      out.push('<polygon points="' + ring(b / 10) + '" fill="none" stroke="#ffffff" stroke-opacity="' + (b === 5 ? '.14' : '.06') + '"/>');
+    }
+    out.push('<polygon points="' + ring(1) + '" fill="#ffffff" fill-opacity=".03" stroke="#ffffff" stroke-opacity=".22"/>');
+    cats.forEach(function (c, k) {
+      var q = at(k, R);
+      out.push('<line x1="' + f(CX) + '" y1="' + CY + '" x2="' + f(q[0]) + '" y2="' + f(q[1]) + '" stroke="#ffffff" stroke-opacity=".08"/>');
+    });
+
+    // The recipient's polygon.
+    if (any) {
+      var pts = cats.map(function (c, k) { var q = at(k, R * level(k)); return f(q[0]) + ',' + f(q[1]); }).join(' ');
+      out.push('<polygon points="' + pts + '" fill="url(#claude-poly)" stroke="' + tier.glow + '" stroke-width="1.6" stroke-linejoin="round"/>');
+      cats.forEach(function (c, k) {
+        var v = stat[k].contributions, q = at(k, R * level(k));
+        if (v) {
+          out.push('<circle cx="' + f(q[0]) + '" cy="' + f(q[1]) + '" r="10" fill="url(#claude-glow)"/>');
+          out.push('<circle cx="' + f(q[0]) + '" cy="' + f(q[1]) + '" r="3.4" fill="#ffffff"/>');
+        }
+      });
+    }
+
+    // The recipient at the centre.
+    out.push('<circle cx="' + f(CX) + '" cy="' + CY + '" r="7" fill="url(#claude-tier)" stroke="#ffffff" stroke-opacity=".9" stroke-width="1.2"/>');
+    out.push('<circle cx="' + f(CX) + '" cy="' + CY + '" r="2" fill="#ffffff"/>');
+
+    // Category labels: name (wrapped when long and set beside the polygon), count, and rank.
+    var LABEL = '700 9px ' + ctx.fonts.sans, TRACK = 0.8, MIN_X = PANEL_X + 14, MAX_X = PANEL_X + PANEL_W - 14;
+    function width(text, font, track) { return ctx.measure(text, font) + (track || 0) * text.length; }
+    cats.forEach(function (c, k) {
+      var a = angle(k), cos = Math.cos(a), sin = Math.sin(a), v = stat[k].contributions;
+      var anchor = cos > 0.35 ? 'start' : (cos < -0.35 ? 'end' : 'middle');
+      var name = c.name.toUpperCase(), lines = [name];
+      if (anchor !== 'middle' && width(name, LABEL, TRACK) > 84 && name.indexOf(' ') > 0) {
+        var words = name.split(' '), best = null;
+        for (var i = 1; i < words.length; i++) {
+          var l1 = words.slice(0, i).join(' '), l2 = words.slice(i).join(' ');
+          var wmax = Math.max(width(l1, LABEL, TRACK), width(l2, LABEL, TRACK));
+          if (!best || wmax < best.w) best = { w: wmax, lines: [l1, l2] };
+        }
+        lines = best.lines;
+      }
+      var count = namesOnly ? '' : (v ? num(v) : '—'), rank = v && !namesOnly ? '#' + num(stat[k].rank) + ' of ' + num(stat[k].of) : '';
+      var w = Math.max.apply(null, lines.map(function (l) { return width(l, LABEL, TRACK); })
+        .concat([width(count, '9.5px ' + ctx.fonts.mono), width(rank, '9px ' + ctx.fonts.mono)]));
+      var q = at(k, R + 15), x = q[0];
+      if (anchor === 'start') x = Math.min(x, MAX_X - w);
+      if (anchor === 'end') x = Math.max(x, MIN_X + w);
+      if (anchor === 'middle') x = Math.min(Math.max(x, MIN_X + w / 2), MAX_X - w / 2);
+      var lh = 11, block = (lines.length + (namesOnly ? 0 : (v ? 2 : 1))) * lh;
+      var top = sin < -0.7 ? q[1] - block + 4 : (sin > 0.7 ? q[1] + 6 : q[1] - block / 2 + 8);
+      var lit = v ? '.92' : '.42';
+      lines.forEach(function (l, i) {
+        out.push('<text x="' + f(x) + '" y="' + f(top + i * lh) + '" text-anchor="' + anchor + '" font-family=\'' + ctx.fonts.sans +
+          '\' font-size="9" font-weight="700" letter-spacing="' + TRACK + '" fill="#ffffff" fill-opacity="' + lit + '">' + ctx.escape(l) + '</text>');
+      });
+      if (namesOnly) return;
+      out.push('<text x="' + f(x) + '" y="' + f(top + lines.length * lh) + '" text-anchor="' + anchor + '" font-family=\'' + ctx.fonts.mono +
+        '\' font-size="9.5" fill="' + (v ? tier.glow : '#ffffff') + '" fill-opacity="' + (v ? '1' : '.4') + '">' + ctx.escape(count) + '</text>');
+      if (v) {
+        out.push('<text x="' + f(x) + '" y="' + f(top + (lines.length + 1) * lh) + '" text-anchor="' + anchor + '" font-family=\'' + ctx.fonts.mono +
+          '\' font-size="9" fill="#ffffff" fill-opacity=".55">' + ctx.escape(rank) + '</text>');
+      }
+    });
+    return out.join('');
+  }
+
+  // The certificate's paper, night-sky panel and frame, shared by the certificate and its
+  // verification record. extraDefs adds definitions of the caller's own.
+  function shell(tier, extraDefs) {
+    var s = [];
+    s.push('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + W + ' ' + H + '" width="' + W + '" height="' + H + '">');
+    s.push('<defs>' +
+      '<radialGradient id="claude-wash-blue" cx="10%" cy="0%" r="70%"><stop offset="0" stop-color="#3788D0" stop-opacity=".13"/><stop offset="1" stop-color="#3788D0" stop-opacity="0"/></radialGradient>' +
+      '<radialGradient id="claude-wash-pink" cx="62%" cy="0%" r="45%"><stop offset="0" stop-color="#e84ea1" stop-opacity=".08"/><stop offset="1" stop-color="#e84ea1" stop-opacity="0"/></radialGradient>' +
+      '<linearGradient id="claude-paper" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#f7faff"/><stop offset=".7" stop-color="#ffffff"/></linearGradient>' +
+      '<linearGradient id="claude-sky" x1="0" y1="0" x2=".35" y2="1"><stop offset="0" stop-color="#081830"/><stop offset=".55" stop-color="#0e2747"/><stop offset="1" stop-color="#16375f"/></linearGradient>' +
+      '<radialGradient id="claude-sky-glow" cx="50%" cy="36%" r="55%"><stop offset="0" stop-color="#3788D0" stop-opacity=".30"/><stop offset="1" stop-color="#3788D0" stop-opacity="0"/></radialGradient>' +
+      '<radialGradient id="claude-glow"><stop offset="0" stop-color="' + tier.glow + '" stop-opacity=".75"/><stop offset="1" stop-color="' + tier.glow + '" stop-opacity="0"/></radialGradient>' +
+      '<radialGradient id="claude-poly" gradientUnits="userSpaceOnUse" cx="' + f(CX) + '" cy="' + CY + '" r="' + R + '"><stop offset="0" stop-color="' + tier.glow + '" stop-opacity=".42"/><stop offset="1" stop-color="' + tier.glow + '" stop-opacity=".16"/></radialGradient>' +
+      '<linearGradient id="claude-tier" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="' + tier.from + '"/><stop offset="1" stop-color="' + tier.to + '"/></linearGradient>' +
+      '<linearGradient id="claude-edge" gradientUnits="userSpaceOnUse" x1="' + f(CX - R) + '" y1="' + (CY - R) + '" x2="' + f(CX + R) + '" y2="' + (CY + R) + '"><stop offset="0" stop-color="#ffffff" stop-opacity=".55"/><stop offset="1" stop-color="' + tier.glow + '" stop-opacity=".55"/></linearGradient>' +
+      '<clipPath id="claude-frame"><rect x="16" y="16" width="' + (W - 32) + '" height="' + (H - 32) + '" rx="18"/></clipPath>' +
+      '<mask id="claude-moon"><rect x="' + (PANEL_X + PANEL_W - 70) + '" y="30" width="60" height="60" fill="#ffffff"/><circle cx="' + (PANEL_X + PANEL_W - 38) + '" cy="52" r="13" fill="#000000"/></mask>' +
+      extraDefs + '</defs>');
+
+    // Paper and frame.
+    s.push('<rect width="' + W + '" height="' + H + '" fill="#eef3f9"/>');
+    s.push('<g clip-path="url(#claude-frame)">');
+    s.push('<rect x="16" y="16" width="' + (W - 32) + '" height="' + (H - 32) + '" fill="url(#claude-paper)"/>');
+    s.push('<rect x="16" y="16" width="' + (W - 32) + '" height="' + (H - 32) + '" fill="url(#claude-wash-blue)"/>');
+    s.push('<rect x="16" y="16" width="' + (W - 32) + '" height="' + (H - 32) + '" fill="url(#claude-wash-pink)"/>');
+
+    // Night-sky panel.
+    s.push('<rect x="' + PANEL_X + '" y="16" width="' + PANEL_W + '" height="' + (H - 32) + '" fill="url(#claude-sky)"/>');
+    s.push('<rect x="' + PANEL_X + '" y="16" width="' + PANEL_W + '" height="' + (H - 32) + '" fill="url(#claude-sky-glow)"/>');
+    for (var k = 0; k < 70; k++) {                            // fixed background starfield, off the chart
+      var sx = PANEL_X + 10 + hash('x' + k) * (PANEL_W - 20), sy = 26 + hash('y' + k) * (H - 52);
+      if (Math.abs(sx - CX) < R + 120 && sy > CY - R - 70 && sy < CY + R + 100) continue;
+      s.push('<circle cx="' + f(sx) + '" cy="' + f(sy) + '" r="' + f(0.4 + hash('r' + k) * 0.8) + '" fill="#ffffff" fill-opacity="' + f(0.08 + hash('o' + k) * 0.22) + '"/>');
+    }
+    s.push('<circle cx="' + (PANEL_X + PANEL_W - 46) + '" cy="58" r="15" fill="#ffffff" fill-opacity=".85" mask="url(#claude-moon)"/>');
+    s.push('</g>');
+    s.push('<rect x="16.5" y="16.5" width="' + (W - 33) + '" height="' + (H - 33) + '" rx="18" fill="none" stroke="#d6e4f5"/>');
+
+
+    return s.join('');
+  }
+
+  // Issuer row, THE ASF lockup and the trademark line.
+  function footer(p, ctx) {
+    var s = [], sans = ctx.fonts.sans, mono = ctx.fonts.mono, e = ctx.escape;
+    // Issuer row.
+    var metaY = 662;
+    s.push('<line x1="' + LEFT + '" y1="' + (metaY - 24) + '" x2="' + (LEFT + CONTENT_W) + '" y2="' + (metaY - 24) + '" stroke="' + LINE + '"/>');
+    [['ISSUED BY', 'The Apache SkyWalking PMC', sans, 0], ['ISSUED ON', p.issued, sans, 236], ['CERTIFICATE ID', p.certId, mono, 400]].forEach(function (m) {
+      s.push('<text x="' + (LEFT + m[3]) + '" y="' + metaY + '" font-family=\'' + sans + '\' font-size="9.5" font-weight="700" letter-spacing="1.5" fill="' + MUTED + '">' + m[0] + '</text>');
+      s.push('<text x="' + (LEFT + m[3]) + '" y="' + (metaY + 20) + '" font-family=\'' + m[2] + '\' font-size="14" font-weight="700" fill="#1F1F20">' + e(m[1]) + '</text>');
+    });
+
+    // THE ASF lockup (official colour file, light ground, clear space kept) and trademark line.
+    var asfH = 52, asfW = asfH * 1000 / 525;
+    s.push('<image href="' + ctx.assets.asfLogo + '" x="' + (LEFT - 6) + '" y="' + (H - 16 - 22 - asfH) + '" width="' + f(asfW) + '" height="' + asfH + '"/>');
+    var tm = wrap(TRADEMARK, '8.5px ' + sans, CONTENT_W - asfW - 14, ctx);
+    s.push(textLines(tm, f(LEFT + asfW + 8), H - 16 - 22 - asfH + 22, 11.5, 'font-family=\'' + sans + '\' font-size="8.5" fill="' + MUTED + '"', ctx));
+
+    return s.join('');
+  }
+
+  function render(p, ctx) {
+    var tier = TIERS[p.role] || TIERS.Contributor;
+    var sans = ctx.fonts.sans, mono = ctx.fonts.mono, e = ctx.escape;
+    var hasData = p.total != null && p.projectCount > 0;
+    var areas = {};
+    (p.projects || []).forEach(function (pr) { areas[pr.catalog] = true; });
+    var areaCount = Object.keys(areas).length, repoCount = 0;
+    ctx.catalogs.forEach(function (c) { repoCount += c.repos.length; });
+    var s = [];
+
+    s.push(shell(tier, ''));
+
+    // Logo, eyebrow, recipient.
+    s.push('<image href="' + ctx.assets.skywalkingLogo + '" x="' + LEFT + '" y="60" width="169" height="40"/>');
+    s.push('<text x="' + LEFT + '" y="150" font-family=\'' + sans + '\' font-size="12.5" font-weight="700" letter-spacing="2.4" fill="' + BRAND + '">CERTIFICATE OF CONTRIBUTION</text>');
+    s.push('<text x="' + LEFT + '" y="184" font-family=\'' + sans + '\' font-size="15" fill="' + BODY + '">This certificate is presented to</text>');
+    var nameSize = ctx.fit(p.displayName, function (z) { return '800 ' + z + 'px ' + sans; }, CONTENT_W, 60, 28);
+    s.push('<text x="' + (LEFT - 2) + '" y="' + (196 + nameSize * 0.86) + '" font-family=\'' + sans + '\' font-size="' + nameSize + '" font-weight="800" letter-spacing="' + f(-nameSize * 0.02) + '" fill="' + INK + '">' + e(p.displayName) + '</text>');
+
+    // Tier pill and identity line.
+    var pillY = 280;
+    var pillText = (p.role + (p.roleNote ? ' · ' + p.roleNote : '')).toUpperCase();
+    var pillW = ctx.measure(pillText, '700 11.5px ' + sans) + 11.5 * 0.12 * pillText.length + 30;
+    s.push('<rect x="' + LEFT + '" y="' + pillY + '" width="' + f(pillW) + '" height="28" rx="14" fill="url(#claude-tier)"/>');
+    s.push('<text x="' + (LEFT + 15) + '" y="' + (pillY + 18.5) + '" font-family=\'' + sans + '\' font-size="11.5" font-weight="700" letter-spacing="1.4" fill="#ffffff">' + e(pillText) + '</text>');
+    var ident = p.kind === 'member'
+      ? 'ASF ID ' + p.apacheId + (p.login ? '  ·  GitHub @' + p.login : '')
+      : 'github.com/' + p.login;
+    s.push('<text x="' + f(LEFT + pillW + 14) + '" y="' + (pillY + 18.5) + '" font-family=\'' + mono + '\' font-size="12" fill="#536478">' + e(ident) + '</text>');
+
+    // Statement.
+    var stmt = wrap(statement(p), '15.5px ' + sans, CONTENT_W, ctx);
+    s.push(textLines(stmt, LEFT, 352, 24, 'font-family=\'' + sans + '\' font-size="15.5" fill="' + BODY + '"', ctx));
+
+    var statsY = 352 + stmt.length * 24 + 26;
+    if (hasData) {
+      // Stats row.
+      var stats = [
+        ['CONTRIBUTIONS', num(p.total), ''],
+        ['PROJECTS', String(p.projectCount), ' / ' + repoCount],
+        ['CATEGORIES', String(areaCount), ' / ' + ctx.catalogs.length]
+      ];
+      stats.forEach(function (st, i) {
+        var x = LEFT + i * 200;
+        s.push('<rect x="' + x + '" y="' + statsY + '" width="3" height="56" rx="1.5" fill="url(#claude-tier)"/>');
+        s.push('<text x="' + (x + 16) + '" y="' + (statsY + 14) + '" font-family=\'' + sans + '\' font-size="10.5" font-weight="700" letter-spacing="1.6" fill="' + MUTED + '">' + st[0] + '</text>');
+        s.push('<text x="' + (x + 15) + '" y="' + (statsY + 50) + '" font-family=\'' + mono + '\' font-size="34" font-weight="700" letter-spacing="-1" fill="' + INK + '">' + e(st[1]) +
+          '<tspan font-size="16" font-weight="500" fill="' + MUTED + '" letter-spacing="0">' + e(st[2]) + '</tspan></text>');
+      });
+
+      // Major contributions.
+      var cardsY = statsY + 92;
+      s.push('<text x="' + LEFT + '" y="' + cardsY + '" font-family=\'' + sans + '\' font-size="10.5" font-weight="700" letter-spacing="1.6" fill="' + MUTED + '">' +
+        (p.top.length > 1 ? 'MAJOR CONTRIBUTIONS' : 'MAJOR CONTRIBUTION') + '</text>');
+      p.top.forEach(function (t, i) {
+        var x = LEFT + i * 308, y = cardsY + 12, w = 292;
+        s.push('<rect x="' + x + '" y="' + y + '" width="' + w + '" height="62" rx="12" fill="#ffffff" stroke="' + LINE + '"/>');
+        var category = ctx.catalogs.filter(function (c) { return c.id === t.catalog; })[0];
+        s.push('<text x="' + (x + 60) + '" y="' + (y + 19) + '" font-family=\'' + sans + '\' font-size="8.5" font-weight="700" letter-spacing="1.2" fill="' + tier.solid + '">' +
+          e((category ? category.name : '').toUpperCase()) + '</text>');
+        var icon = t.icon && ctx.assets.projectIcons[t.icon];
+        if (icon) {
+          s.push('<image href="' + icon + '" x="' + (x + 15) + '" y="' + (y + 15) + '" width="32" height="32"/>');
+        } else {
+          s.push('<rect x="' + (x + 15) + '" y="' + (y + 15) + '" width="32" height="32" rx="9" fill="#eef5fd" stroke="#d6e4f5"/>');
+          s.push('<text x="' + (x + 31) + '" y="' + (y + 36) + '" text-anchor="middle" font-family=\'' + sans + '\' font-size="14" font-weight="800" fill="#2c6cb0">' + e(t.label.charAt(0)) + '</text>');
+        }
+        var labelSize = ctx.fit(t.label, function (z) { return '700 ' + z + 'px ' + sans; }, w - 76, 15, 11);
+        s.push('<text x="' + (x + 60) + '" y="' + (y + 35) + '" font-family=\'' + sans + '\' font-size="' + labelSize + '" font-weight="700" fill="#1F1F20">' + e(t.label) +
+          (t.archived ? '<tspan font-size="9.5" font-weight="600" fill="' + MUTED + '" letter-spacing=".8">  ARCHIVED</tspan>' : '') + '</text>');
+        s.push('<text x="' + (x + 60) + '" y="' + (y + 51) + '" font-family=\'' + mono + '\' font-size="11" fill="#536478">' +
+          e(num(t.contributions) + (t.contributions === 1 ? ' contribution' : ' contributions') + '  ·  #' + t.rank + ' of ' + num(t.of)) + '</text>');
+      });
+    } else {
+      // Members without GitHub data: a role card instead of stats.
+      var y0 = statsY;
+      s.push('<rect x="' + LEFT + '" y="' + y0 + '" width="' + CONTENT_W + '" height="92" rx="14" fill="#ffffff" stroke="' + LINE + '"/>');
+      s.push('<rect x="' + LEFT + '" y="' + y0 + '" width="4" height="92" rx="2" fill="url(#claude-tier)"/>');
+      s.push('<text x="' + (LEFT + 24) + '" y="' + (y0 + 30) + '" font-family=\'' + sans + '\' font-size="10.5" font-weight="700" letter-spacing="1.6" fill="' + MUTED + '">ROLE</text>');
+      s.push('<text x="' + (LEFT + 24) + '" y="' + (y0 + 60) + '" font-family=\'' + sans + '\' font-size="22" font-weight="800" fill="' + INK + '">' + e(p.role === 'PMC Member' ? 'Project Management Committee' : p.role) + '</text>');
+      s.push('<text x="' + (LEFT + 24) + '" y="' + (y0 + 78) + '" font-family=\'' + mono + '\' font-size="11" fill="#536478">' +
+        e('Listed at skywalking.apache.org/team/#' + p.apacheId) + '</text>');
+    }
+
+    s.push(footer(p, ctx));
+
+    // Sky panel content.
+    s.push('<text x="' + (PANEL_X + 28) + '" y="64" font-family=\'' + sans + '\' font-size="10.5" font-weight="700" letter-spacing="2" fill="#ffffff" fill-opacity=".8">CONTRIBUTION MAP</text>');
+    s.push('<text x="' + (PANEL_X + 28) + '" y="82" font-family=\'' + sans + '\' font-size="10.5" fill="#ffffff" fill-opacity=".5">By category, across all ' + repoCount + ' Apache SkyWalking repositories</text>');
+    s.push(polygon(p, ctx, tier));
+    var captionY = CY + R + 78;
+    var caption = hasData
+      ? p.projectCount + ' of ' + repoCount + ' repositories  ·  ' + areaCount + ' of ' + ctx.catalogs.length + ' categories'
+      : repoCount + ' repositories in ' + ctx.catalogs.length + ' categories';
+    s.push('<text x="' + f(CX) + '" y="' + captionY + '" text-anchor="middle" font-family=\'' + mono + '\' font-size="10.5" fill="#ffffff" fill-opacity=".7">' + e(caption) + '</text>');
+
+    // QR tile.
+    var tile = 148, qrSize = 124, tx = CX - tile / 2, ty = 536;
+    s.push('<rect x="' + f(tx) + '" y="' + ty + '" width="' + tile + '" height="' + tile + '" rx="12" fill="#ffffff"/>');
+    s.push('<path d="' + ctx.qr(p.verifyUrl).path(tx + (tile - qrSize) / 2, ty + (tile - qrSize) / 2, qrSize) + '" fill="#0e2747"/>');
+    s.push('<text x="' + f(CX) + '" y="' + (ty + tile + 24) + '" text-anchor="middle" font-family=\'' + sans + '\' font-size="9.5" font-weight="700" letter-spacing="1.5" fill="#ffffff" fill-opacity=".6">SCAN TO VERIFY</text>');
+
+    s.push('</svg>');
+    return s.join('');
+  }
+
+  // A rubber stamp: the project's name on the upper ring, the issue date on the lower ring and
+  // VERIFIED across a band, rotated a little and inked unevenly.
+  var STAMP = '#1f8a52';
+  function stamp(cx, cy, issued, ctx) {
+    var sans = ctx.fonts.sans, r = 62, ar = r - 15, out = [];
+    out.push('<g transform="rotate(-12 ' + cx + ' ' + cy + ')" filter="url(#claude-ink)" fill="' + STAMP + '" stroke="' + STAMP + '">');
+    out.push('<circle cx="' + cx + '" cy="' + cy + '" r="' + r + '" fill="none" stroke-width="3.2"/>');
+    out.push('<circle cx="' + cx + '" cy="' + cy + '" r="' + (r - 6) + '" fill="none" stroke-width="1.1"/>');
+    // Upper arc runs clockwise (text sits outside the path), lower arc counter-clockwise, so
+    // both read left to right.
+    out.push('<path id="claude-stamp-top" fill="none" stroke="none" d="M' + (cx - ar) + ' ' + cy + 'A' + ar + ' ' + ar + ' 0 0 1 ' + (cx + ar) + ' ' + cy + '"/>');
+    var br = ar + 7;
+    out.push('<path id="claude-stamp-bottom" fill="none" stroke="none" d="M' + (cx - br) + ' ' + cy + 'A' + br + ' ' + br + ' 0 0 0 ' + (cx + br) + ' ' + cy + '"/>');
+    out.push('<text font-family=\'' + sans + '\' font-size="8" font-weight="800" letter-spacing="0.9" stroke="none"><textPath href="#claude-stamp-top" startOffset="50%" text-anchor="middle">APACHE SKYWALKING</textPath></text>');
+    out.push('<text font-family=\'' + sans + '\' font-size="9" font-weight="800" letter-spacing="1.4" stroke="none"><textPath href="#claude-stamp-bottom" startOffset="50%" text-anchor="middle">' + ctx.escape(issued.toUpperCase()) + '</textPath></text>');
+    out.push('<rect x="' + (cx - r - 12) + '" y="' + (cy - 15) + '" width="' + (2 * r + 24) + '" height="30" fill="#ffffff" stroke-width="2.4"/>');
+    out.push('<text x="' + cx + '" y="' + (cy + 8) + '" text-anchor="middle" font-family=\'' + sans + '\' font-size="22" font-weight="900" letter-spacing="2.8" stroke="none">VERIFIED</text>');
+    out.push('<text x="' + cx + '" y="' + (cy - 22) + '" text-anchor="middle" font-family=\'' + sans + '\' font-size="7" font-weight="800" letter-spacing="1.8" stroke="none">ISSUED</text>');
+    out.push('<text x="' + cx + '" y="' + (cy + 27) + '" text-anchor="middle" font-family=\'' + sans + '\' font-size="9" stroke="none">★</text>');
+    out.push('</g>');
+    return out.join('');
+  }
+
+  /*
+   * The verification record of a certificate, drawn from the facts of its verify link:
+   * v = { displayName, kind, apacheId, login, role, total, projects, bands, categories, issued,
+   * certId, date }. Bands give the polygon; categories are the category names published with
+   * that day's record, so the polygon is labelled as issued (unlabelled when they are unknown).
+   */
+  function renderVerification(v, ctx) {
+    var tier = TIERS[v.role] || TIERS.Contributor;
+    var sans = ctx.fonts.sans, mono = ctx.fonts.mono, e = ctx.escape;
+    var bands = v.bands.split('').map(function (b) { return parseInt(b, 11); });
+    var names = v.categories && v.categories.length === bands.length ? v.categories : null;
+    var cats = bands.map(function (b, i) { return { id: 'c' + i, name: names ? String(names[i]) : '', repos: [] }; });
+    var categories = cats.map(function (c, i) { return { id: c.id, contributions: bands[i] ? 1 : 0, band: bands[i] }; });
+    var lit = bands.filter(Boolean).length;
+    var vctx = Object.assign({}, ctx, { catalogs: cats });
+    var s = [];
+    s.push(shell(tier, '<filter id="claude-ink" x="-15%" y="-15%" width="130%" height="130%">' +
+      '<feTurbulence type="fractalNoise" baseFrequency="1.4" numOctaves="2" seed="11" result="noise"/>' +
+      '<feColorMatrix in="noise" type="matrix" values="0 0 0 0 0  0 0 0 0 0  0 0 0 0 0  0 0 0 -1.5 1.45" result="speck"/>' +
+      '<feComposite in="SourceGraphic" in2="speck" operator="in"/></filter>'));
+
+    // Logo, eyebrow, recipient.
+    s.push('<image href="' + ctx.assets.skywalkingLogo + '" x="' + LEFT + '" y="60" width="169" height="40"/>');
+    s.push('<text x="' + LEFT + '" y="150" font-family=\'' + sans + '\' font-size="12.5" font-weight="700" letter-spacing="2.4" fill="' + BRAND + '">CERTIFICATE OF CONTRIBUTION  ·  VERIFICATION</text>');
+    s.push('<text x="' + LEFT + '" y="184" font-family=\'' + sans + '\' font-size="15" fill="' + BODY + '">This certificate was issued to</text>');
+    var nameSize = ctx.fit(v.displayName, function (z) { return '800 ' + z + 'px ' + sans; }, CONTENT_W, 60, 28);
+    s.push('<text x="' + (LEFT - 2) + '" y="' + (196 + nameSize * 0.86) + '" font-family=\'' + sans + '\' font-size="' + nameSize + '" font-weight="800" letter-spacing="' + f(-nameSize * 0.02) + '" fill="' + INK + '">' + e(v.displayName) + '</text>');
+    var pillY = 280, pillText = v.role.toUpperCase();
+    var pillW = ctx.measure(pillText, '700 11.5px ' + sans) + 11.5 * 0.12 * pillText.length + 30;
+    s.push('<rect x="' + LEFT + '" y="' + pillY + '" width="' + f(pillW) + '" height="28" rx="14" fill="url(#claude-tier)"/>');
+    s.push('<text x="' + (LEFT + 15) + '" y="' + (pillY + 18.5) + '" font-family=\'' + sans + '\' font-size="11.5" font-weight="700" letter-spacing="1.4" fill="#ffffff">' + e(pillText) + '</text>');
+    var ident = v.kind === 'member' ? 'ASF ID ' + v.apacheId : 'github.com/' + v.login;
+    s.push('<text x="' + f(LEFT + pillW + 14) + '" y="' + (pillY + 18.5) + '" font-family=\'' + mono + '\' font-size="12" fill="#536478">' + e(ident) + '</text>');
+
+    var stmt = wrap('by the Apache SkyWalking PMC on ' + v.issued + '. Every fact on this record is exactly as issued: ' +
+      'the certificate code covers them, and it is listed among the certificates skywalking.apache.org issued that day.', '15.5px ' + sans, CONTENT_W, ctx);
+    s.push(textLines(stmt, LEFT, 352, 24, 'font-family=\'' + sans + '\' font-size="15.5" fill="' + BODY + '"', ctx));
+
+    var statsY = 352 + stmt.length * 24 + 26;
+    var stats = v.total != null
+      ? [['CONTRIBUTIONS', num(v.total), ''], ['PROJECTS', String(v.projects), ''], ['CATEGORIES', String(lit), ' / ' + bands.length]]
+      : [['ROLE', v.role === 'PMC Member' ? 'PMC' : v.role, '']];
+    stats.forEach(function (st, i) {
+      var x = LEFT + i * 200;
+      s.push('<rect x="' + x + '" y="' + statsY + '" width="3" height="56" rx="1.5" fill="url(#claude-tier)"/>');
+      s.push('<text x="' + (x + 16) + '" y="' + (statsY + 14) + '" font-family=\'' + sans + '\' font-size="10.5" font-weight="700" letter-spacing="1.6" fill="' + MUTED + '">' + st[0] + '</text>');
+      s.push('<text x="' + (x + 15) + '" y="' + (statsY + 50) + '" font-family=\'' + mono + '\' font-size="34" font-weight="700" letter-spacing="-1" fill="' + INK + '">' + e(st[1]) +
+        '<tspan font-size="16" font-weight="500" fill="' + MUTED + '" letter-spacing="0">' + e(st[2]) + '</tspan></text>');
+    });
+
+    // How it was checked, beside the stamp.
+    var noteY = statsY + 96;
+    s.push('<text x="' + LEFT + '" y="' + noteY + '" font-family=\'' + sans + '\' font-size="10.5" font-weight="700" letter-spacing="1.6" fill="' + MUTED + '">HOW THIS IS VERIFIED</text>');
+    var how = wrap('The code ' + v.certId + ' is recomputed from the facts above and found in the published record ' +
+      'skywalking.apache.org/certificate/verify/' + v.date + '.txt.', '12.5px ' + sans, CONTENT_W - 190, ctx);
+    s.push(textLines(how, LEFT, noteY + 22, 19, 'font-family=\'' + sans + '\' font-size="12.5" fill="' + BODY + '"', ctx));
+
+    s.push(footer({ issued: v.issued, certId: v.certId }, ctx));
+    s.push(stamp(LEFT + CONTENT_W - 72, Math.max(noteY + 24, statsY + 128), v.issued, ctx));
+
+    // Sky panel: the polygon as issued.
+    s.push('<text x="' + (PANEL_X + 28) + '" y="64" font-family=\'' + sans + '\' font-size="10.5" font-weight="700" letter-spacing="2" fill="#ffffff" fill-opacity=".8">CONTRIBUTION MAP</text>');
+    s.push('<text x="' + (PANEL_X + 28) + '" y="82" font-family=\'' + sans + '\' font-size="10.5" fill="#ffffff" fill-opacity=".5">As issued on ' + e(v.issued) + '</text>');
+    s.push(polygon({ categories: categories }, vctx, tier, true));
+    var captionY = CY + R + 78;
+    s.push('<text x="' + f(CX) + '" y="' + captionY + '" text-anchor="middle" font-family=\'' + mono + '\' font-size="10.5" fill="#ffffff" fill-opacity=".7">' +
+      e(lit + ' of ' + bands.length + ' categories') + '</text>');
+    [['ISSUED ON', v.issued, sans, H - 140], ['CERTIFICATE ID', v.certId, mono, H - 84]].forEach(function (m) {
+      s.push('<text x="' + f(CX) + '" y="' + m[3] + '" text-anchor="middle" font-family=\'' + sans + '\' font-size="9.5" font-weight="700" letter-spacing="1.5" fill="#ffffff" fill-opacity=".6">' + m[0] + '</text>');
+      s.push('<text x="' + f(CX) + '" y="' + (m[3] + 22) + '" text-anchor="middle" font-family=\'' + m[2] + '\' font-size="16" font-weight="700" fill="#ffffff">' + e(m[1]) + '</text>');
+    });
+
+    s.push('</svg>');
+    return s.join('');
+  }
+
+  SWCert.designs.claude = { id: 'claude', title: 'Contribution Sky Map', render: render, renderVerification: renderVerification };
+})();
diff --git a/assets/js/certificate/model.js b/assets/js/certificate/model.js
new file mode 100644
index 000000000000..90d02bbf1017
--- /dev/null
+++ b/assets/js/certificate/model.js
@@ -0,0 +1,148 @@
+/*
+ * Certificate model, shared by the browser (assets/js/certificate/page.js) and the build
+ * (scripts/certificate.js), so a certificate drawn in the browser and the list of issued codes
+ * written by the build always agree.
+ *
+ * A certificate is identified by its issue date and a code: the first 80 bits of the SHA-256
+ * of its verified facts (date, person, role, total contributions, projects, and the rank band
+ * of every category), in Crockford base32. The verify link carries the date, the code and the
+ * facts; the build publishes each day's codes in certificate/verify/<date>.txt.
+ */
+(function (root, factory) {
+  if (typeof module === 'object' && module.exports) module.exports = factory();
+  else root.SWCertModel = factory();
+})(typeof self !== 'undefined' ? self : this, function () {
+  'use strict';
+
+  var VERSION = 'v1';
+  var ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
+  var ROLE = { 'PMC Member': 'p', 'Committer': 'c', 'Contributor': 'x' };
+  var ROLE_NAME = { p: 'PMC Member', c: 'Committer', x: 'Contributor' };
+
+  // Lookup tables without a prototype, so a login or Apache ID such as "constructor" or
+  // "__proto__" is an ordinary key.
+  function table() { return Object.create(null); }
+
+  function index(data) {
+    var logins = table(), members = table(), memberByGithub = table();
+    data.catalogs.forEach(function (c) {
+      c.repos.forEach(function (r) {
+        r.contributors.forEach(function (row) {
+          var key = row.l.toLowerCase();
+          var entry = logins[key] = logins[key] || { login: row.l, projects: [] };
+          entry.projects.push({
+            name: r.name, label: r.label, icon: r.icon || null, archived: !!r.archived, catalog: c.id,
+            contributions: row.n, rank: row.r, of: r.of
+          });
+        });
+      });
+    });
+    [['pmc', 'PMC Member'], ['committer', 'Committer']].forEach(function (g) {
+      (data.members[g[0]] || []).forEach(function (m) {
+        var member = { name: m.name, apacheId: m.apacheId, github: m.github || null, role: g[1] };
+        members[m.apacheId.toLowerCase()] = member;
+        if (m.github) memberByGithub[m.github.toLowerCase()] = member;
+      });
+    });
+    // Every listed person's total in each category, highest first, for category ranks.
+    var categoryTotals = table();
+    data.catalogs.forEach(function (c) { categoryTotals[c.id] = []; });
+    Object.keys(logins).forEach(function (k) {
+      var sums = table();
+      logins[k].projects.forEach(function (p) { sums[p.catalog] = (sums[p.catalog] || 0) + p.contributions; });
+      Object.keys(sums).forEach(function (id) { categoryTotals[id].push(sums[id]); });
+    });
+    Object.keys(categoryTotals).forEach(function (id) {
+      categoryTotals[id].sort(function (a, b) { return b - a; });
+    });
+    return { logins: logins, members: members, memberByGithub: memberByGithub, categoryTotals: categoryTotals };
+  }
+
+  // Competition ranking: 1 + the number of people with strictly more, so ties share a rank.
+  function rankIn(descending, value) {
+    var lo = 0, hi = descending.length;
+    while (lo < hi) {
+      var mid = (lo + hi) >> 1;
+      if (descending[mid] > value) lo = mid + 1; else hi = mid;
+    }
+    return lo + 1;
+  }
+
+  // Find who a query refers to. Members win, so a committer's GitHub link still yields their role.
+  function resolve(idx, query) {
+    var member = query.member ? idx.members[query.member.toLowerCase()] : null;
+    var login = query.contributor || null;
+    if (!member && login) member = idx.memberByGithub[login.toLowerCase()] || null;
+    if (!member && !login && query.q) {
+      member = idx.members[query.q.toLowerCase()] || idx.memberByGithub[query.q.toLowerCase()] || null;
+      login = member ? null : query.q;
+    }
+    if (member) login = member.github;
+    var stats = login ? idx.logins[login.toLowerCase()] || null : null;
+    if (!member && !stats) return null;
+    return { member: member, stats: stats };
+  }
+
+  // A person's standing in every category, in catalog order. The band (0-10) places the
+  // category's vertex on the contribution polygon: rank in 10% steps, 10 for the top 10%.
+  function categories(data, idx, found) {
+    var sums = table();
+    (found.stats ? found.stats.projects : []).forEach(function (p) {
+      sums[p.catalog] = (sums[p.catalog] || 0) + p.contributions;
+    });
+    return data.catalogs.map(function (c) {
+      var v = sums[c.id] || 0, all = idx.categoryTotals[c.id];
+      var rank = v ? rankIn(all, v) : null;
+      return {
+        id: c.id, name: c.name, contributions: v, rank: rank, of: all.length,
+        band: v ? 10 - Math.floor((rank - 1) * 10 / all.length) : 0
+      };
+    });
+  }
+
+  // The facts a certificate's code covers.
+  function facts(data, idx, found) {
+    var m = found.member, projects = found.stats ? found.stats.projects : [];
+    return {
+      key: m ? 'member:' + m.apacheId.toLowerCase() : 'contributor:' + found.stats.login.toLowerCase(),
+      role: m ? ROLE[m.role] : 'x',
+      total: projects.length ? projects.reduce(function (s, p) { return s + p.contributions; }, 0) : null,
+      projects: projects.length,
+      bands: categories(data, idx, found).map(function (c) { return c.band.toString(11); }).join('')
+    };
+  }
+
+  function canonical(date, f) {
+    return [VERSION, date, f.key, f.role, f.total == null ? '' : f.total, f.projects, f.bands].join('|');
+  }
+
+  // The first 80 bits of a SHA-256 digest (bytes) as 16 Crockford base32 characters.
+  function encode(bytes) {
+    var out = '', bits = 0, value = 0;
+    for (var i = 0; i < 10; i++) {
+      value = (value << 8) | bytes[i];
+      bits += 8;
+      while (bits >= 5) {
+        out += ALPHABET[(value >>> (bits - 5)) & 31];
+        bits -= 5;
+      }
+      value &= (1 << bits) - 1;
+    }
+    return out;
+  }
+
+  // Everyone the site lists on a given day: members, then contributors who are not members.
+  function everyone(idx) {
+    var people = Object.keys(idx.members).map(function (id) { return { member: id }; });
+    Object.keys(idx.logins).forEach(function (login) {
+      if (!idx.memberByGithub[login]) people.push({ contributor: login });
+    });
+    return people;
+  }
+
+  return {
+    VERSION: VERSION, ROLE: ROLE, ROLE_NAME: ROLE_NAME,
+    index: index, rankIn: rankIn, resolve: resolve, categories: categories,
+    facts: facts, canonical: canonical, encode: encode, everyone: everyone
+  };
+});
diff --git a/assets/js/certificate/page.js b/assets/js/certificate/page.js
new file mode 100644
index 000000000000..96e156c8d0a4
--- /dev/null
+++ b/assets/js/certificate/page.js
@@ -0,0 +1,293 @@
+/*
+ * /certificate page controller.
+ *
+ * Issuing: ?contributor=<GitHub login> or ?member=<Apache ID> draws a certificate from today's
+ * data (assets/certificate/people.json, see scripts/certificate.js). Its ID is the data date plus
+ * a code covering its facts (SWCertModel), and its QR code links back here with both.
+ *
+ * Verifying: a link with &id=<yyyymmdd>-<code> carries the certificate's facts. The page
+ * recomputes the code from them, checks it against the ID, and looks it up in that day's list
+ * of issued codes (certificate/verify/<yyyy-mm-dd>.txt), which the site keeps for good.
+ */
+(function () {
+  'use strict';
+
+  var app = document.getElementById('cert-app');
+  if (!app) return;
+  var M = window.SWCertModel;
+  var $ = function (sel) { return app.querySelector(sel); };
+  var SITE = (app.dataset.base || 'https://skywalking.apache.org/').replace(/\/?$/, '/');
+  var ID = /^(\d{4})(\d{2})(\d{2})-([0-9A-HJKMNP-TV-Z]{16})$/;
+  var MONTHS = ['Jan.', 'Feb.', 'Mar.', 'Apr.', 'May', 'Jun.', 'Jul.', 'Aug.', 'Sep.', 'Oct.', 'Nov.', 'Dec.'];
+
+  function siteDate(iso) {
+    var d = new Date(iso + 'T00:00:00'), day = d.getDate();
+    var suffix = (day % 100 >= 11 && day % 100 <= 13) ? 'th' : ({ 1: 'st', 2: 'nd', 3: 'rd' }[day % 10] || 'th');
+    return MONTHS[d.getMonth()] + ' ' + day + suffix + ', ' + d.getFullYear();
+  }
+
+  function codeFor(date, facts) {
+    return crypto.subtle.digest('SHA-256', new TextEncoder().encode(M.canonical(date, facts)))
+      .then(function (buf) { return M.encode(new Uint8Array(buf)); });
+  }
+
+  function groupCode(code) { return code.match(/.{4}/g).join('-'); }
+
+  function esc(s) {
+    return String(s).replace(/[&<>"']/g, function (c) {
+      return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
+    });
+  }
+
+  function plural(n, word) { return n.toLocaleString('en-US') + ' ' + word + (n === 1 ? '' : 's'); }
+  function asRole(role) { return role === 'PMC Member' ? 'a PMC member' : 'a ' + role.toLowerCase(); }
+  function splitName(name) {
+    var t = /^(.*?)\s*\((.*)\)\s*$/.exec(name);
+    return t ? { name: t[1], note: t[2] } : { name: name, note: null };
+  }
+  var TICK = '<span class="tick" aria-hidden="true">✓</span>';
+
+  var state = { data: null, idx: null, ctx: null, person: null, svg: null };
+
+  // Every lookup gets a generation; an answer that arrives after a newer lookup started is
+  // dropped, so a slow verification can never overwrite the result of a later search.
+  var generation = 0;
+  function stale(token) { return token !== generation; }
+
+  function setStatus(kind, html) {
+    var el = $('.cert-status');
+    el.className = 'cert-status ' + kind;
+    el.innerHTML = html;
+  }
+
+  function fail(html, token) {
+    if (stale(token)) return;
+    state.person = null;
+    $('.cert-result').hidden = true;
+    $('.cert-verify').hidden = true;
+    setStatus('missing', html);
+  }
+
+  // ---- Issuing ------------------------------------------------------------------------------
+
+  function buildPerson(found) {
+    var data = state.data, m = found.member, st = found.stats;
+    var projects = st ? st.projects.slice().sort(function (a, b) {
+      return b.contributions - a.contributions || a.rank - b.rank;
+    }) : [];
+    var named = m ? splitName(m.name) : null;
+    var facts = M.facts(data, state.idx, found);
+    var date = data.generated;
+    return codeFor(date, facts).then(function (code) {
+      var id = date.replace(/-/g, '') + '-' + code;
+      var query = (m ? 'member=' + encodeURIComponent(m.apacheId) : 'contributor=' + encodeURIComponent(st.login)) +
+        '&id=' + id + '&r=' + facts.role + (facts.total == null ? '' : '&t=' + facts.total) +
+        '&n=' + facts.projects + '&b=' + facts.bands;
+      return {
+        kind: m ? 'member' : 'contributor',
+        displayName: m ? named.name : '@' + st.login,
+        login: st ? st.login : (m && m.github) || null, apacheId: m ? m.apacheId : null,
+        role: m ? m.role : 'Contributor', roleNote: named ? named.note : null,
+        total: facts.total, projectCount: facts.projects, top: projects.slice(0, 2), projects: projects,
+        categories: M.categories(data, state.idx, found),
+        verifyUrl: SITE + 'certificate/?' + query,
+        certId: groupCode(code),
+        issued: siteDate(date)
+      };
+    });
+  }
+
+  function issue(p, token) {
+    var found = M.resolve(state.idx, p);
+    if (!found) {
+      return fail('<strong>Not found.</strong> No PMC member, committer or contributor <code>' + esc(p.member || p.contributor || p.q) +
+        '</code> is listed on skywalking.apache.org. Contributors are matched by GitHub login; anonymous commits ' +
+        'and bots are not listed.', token);
+    }
+    setStatus('idle', 'Preparing the certificate…');
+    return buildPerson(found).then(function (person) {
+      if (stale(token)) return;
+      state.person = person;
+      var detail = person.total != null
+        ? plural(person.total, 'contribution') + ' across ' + plural(person.projectCount, 'project')
+        : 'no public commit statistics on file';
+      // Issuing is not verification: that only happens when a certificate's verify link is opened.
+      setStatus('issued', '<span><strong>Certificate generated</strong> for ' + esc(person.displayName) + ', listed on skywalking.apache.org as ' +
+        asRole(person.role) + (person.roleNote ? ' (' + esc(person.roleNote) + ')' : '') + ', with ' + detail +
+        ' as of ' + esc(person.issued) + '. Certificate ID <code>' + esc(person.certId) + '</code>; anyone can verify it from its QR code or verify link.</span>');
+      $('.cert-verify').hidden = true;
+      $('.cert-result').hidden = false;
+      state.svg = SWCert.render('claude', person, state.ctx);
+      $('.cert-result .cert-canvas').innerHTML = state.svg;
+    }).catch(function (err) {
+      fail('Could not draw the certificate: ' + esc(err.message), token);
+    });
+  }
+
+  // ---- Verifying ----------------------------------------------------------------------------
+
+  function claimedFacts(p) {
+    var key = p.member ? 'member:' + p.member.toLowerCase() : (p.contributor ? 'contributor:' + p.contributor.toLowerCase() : null);
+    if (!key || (p.member && p.contributor)) return null;
+    if (!/^[pcx]$/.test(p.r || '') || !/^\d*$/.test(p.t || '') || !/^\d+$/.test(p.n || '') || !/^[0-9a]+$/.test(p.b || '')) return null;
+    return { key: key, role: p.r, total: p.t ? Number(p.t) : null, projects: Number(p.n), bands: p.b };
+  }
+
+  // A day's record: '#' header lines (the day's categories, in polygon order) and one code per
+  // line. Lines may come in any order; differing headers leave the categories unknown.
+  function issuedRecord(date) {
+    return fetch(app.dataset.verify + date + '.txt').then(function (r) {
+      if (!r.ok) return null;
+      return r.text().then(function (text) {
+        var codes = [], headers = [];
+        text.split('\n').forEach(function (line) {
+          line = line.trim();
+          if (line.charAt(0) === '#') headers.push(line);
+          else if (line) codes.push(line);
+        });
+        var categories = null;
+        if (headers.length && headers.every(function (h) { return h === headers[0]; })) {
+          try { categories = JSON.parse(headers[0].slice(1)).categories || null; } catch (e) { categories = null; }
+        }
+        return { codes: codes, categories: categories };
+      });
+    });
+  }
+
+  function verify(p, token) {
+    var parts = ID.exec(p.id || '');
+    var facts = claimedFacts(p);
+    if (!parts || !facts) return fail('<strong>Not verifiable.</strong> This link is not a complete certificate verification link.', token);
+    var date = parts[1] + '-' + parts[2] + '-' + parts[3], code = parts[4], shown = groupCode(code);
+    setStatus('idle', 'Checking certificate <code>' + esc(shown) + '</code>…');
+    return codeFor(date, facts).then(function (expected) {
+      if (stale(token)) return;
+      if (expected !== code) {
+        return fail('<strong>Not authentic.</strong> The facts in this link do not match certificate <code>' + esc(shown) +
+          '</code>: they have been changed since it was issued.', token);
+      }
+      return issuedRecord(date).then(function (record) {
+        if (stale(token)) return;
+        if (!record) return fail('<strong>Not verifiable.</strong> No certificate records are published for ' + esc(siteDate(date)) + '.', token);
+        if (record.codes.indexOf(code) < 0) {
+          return fail('<strong>Not authentic.</strong> Certificate <code>' + esc(shown) + '</code> was not issued on ' + esc(siteDate(date)) + '.', token);
+        }
+        showVerified(p, facts, date, shown, record.categories);
+      });
+    }).catch(function (err) {
+      fail('<strong>Not verifiable right now.</strong> The certificate records could not be loaded (' + esc(err.message) +
+        '). Try again later.', token);
+    });
+  }
+
+  function showVerified(p, facts, date, shown, categories) {
+    var now = M.resolve(state.idx, p.member ? { member: p.member } : { contributor: p.contributor });
+    var role = M.ROLE_NAME[facts.role];
+    var name = now && now.member ? splitName(now.member.name).name : (p.member || '@' + p.contributor);
+    setStatus('verified', TICK + '<span><strong>Authentic.</strong> Certificate <code>' + esc(shown) + '</code> was issued to ' +
+      esc(name) + ' on ' + esc(siteDate(date)) + ', and every fact below matches it.</span>');
+    $('.cert-verify-canvas').innerHTML = SWCert.designs.claude.renderVerification({
+      displayName: name, kind: p.member ? 'member' : 'contributor', apacheId: p.member, login: p.contributor,
+      role: role, total: facts.total, projects: facts.projects, bands: facts.bands,
+      categories: categories, issued: siteDate(date), certId: shown, date: date
+    }, state.ctx);
+    $('.cert-verify-today').innerHTML = todayLine(now, name, role, facts) +
+      ' <a href="' + esc(location.pathname + '?' + (p.member ? 'member=' + encodeURIComponent(p.member) : 'contributor=' + encodeURIComponent(p.contributor))) +
+      '">See today\'s certificate &rarr;</a>';
+    $('.cert-result').hidden = true;
+    $('.cert-verify').hidden = false;
+  }
+
+  function todayLine(now, name, role, facts) {
+    if (!now) return 'Today ' + esc(name) + ' is no longer listed on skywalking.apache.org.';
+    var roleNow = now.member ? now.member.role : 'Contributor';
+    var line = 'Today ' + esc(name) + ' is ' + (roleNow === role ? 'still listed as ' + asRole(roleNow)
+      : 'listed as ' + asRole(roleNow) + ' (' + role.toLowerCase() + ' when issued)');
+    var total = now.stats ? now.stats.projects.reduce(function (s, x) { return s + x.contributions; }, 0) : null;
+    if (facts.total == null || total == null) return line + '.';
+    if (total === facts.total) return line + ', with the same ' + plural(total, 'contribution') + '.';
+    if (total > facts.total) return line + ', with ' + plural(total, 'contribution') + ' (' + facts.total.toLocaleString('en-US') + ' when issued).';
+    return line + ', with ' + plural(total, 'contribution') + ', fewer than the ' + facts.total.toLocaleString('en-US') +
+      ' when issued. That happens when commit history is rewritten or a repository leaves the site.';
+  }
+
+  // ---- Page ---------------------------------------------------------------------------------
+
+  function params() {
+    var q = new URLSearchParams(location.search), p = {};
+    ['contributor', 'member', 'q', 'id', 'r', 't', 'n', 'b'].forEach(function (k) { p[k] = q.get(k); });
+    return p;
+  }
+
+  function show(p) {
+    var token = ++generation;
+    state.person = null;
+    state.svg = null;
+    $('.cert-result').hidden = true;
+    $('.cert-verify').hidden = true;
+    if (p.id) return verify(p, token);
+    if (!(p.member || p.contributor || p.q)) {
+      setStatus('idle', 'Enter a GitHub login or an Apache ID to generate a certificate.');
+      return;
+    }
+    return issue(p, token);
+  }
+
+  function navigate(p) {
+    var q = new URLSearchParams();
+    if (p.member) q.set('member', p.member);
+    else if (p.contributor) q.set('contributor', p.contributor);
+    else if (p.q) q.set('q', p.q);
+    history.pushState(null, '', location.pathname + (q.toString() ? '?' + q : ''));
+    show(p);
+  }
+
+  $('.cert-png').addEventListener('click', function () {
+    if (state.svg) SWCert.downloadPNG(state.svg, state.person, 2);
+  });
+  $('.cert-print').addEventListener('click', function () { window.print(); });
+  $('.cert-copy').addEventListener('click', function (ev) {
+    var btn = ev.currentTarget;
+    navigator.clipboard.writeText(state.person.verifyUrl).then(function () {
+      btn.classList.add('copied');
+      setTimeout(function () { btn.classList.remove('copied'); }, 1600);
+    });
+  });
+
+  var form = document.querySelector('.cert-search');
+  if (form) {
+    form.addEventListener('submit', function (ev) {
+      ev.preventDefault();
+      var v = form.q.value.trim().replace(/^@/, '');
+      if (v) navigate({ q: v });
+    });
+  }
+  window.addEventListener('popstate', function () { show(params()); });
+
+  setStatus('idle', 'Loading contribution data…');
+  fetch(app.dataset.people).then(function (r) {
+    if (!r.ok) throw new Error('certificate data is not published');
+    return r.json();
+  }).then(function (data) {
+    state.data = data;
+    state.idx = M.index(data);
+    return SWCert.buildContext({
+      files: {
+        skywalkingLogo: app.dataset.logo,
+        skywalkingLogoWhite: app.dataset.logoWhite,
+        asfLogo: app.dataset.asfLogo,
+        asfOakleaf: app.dataset.asfLeaf
+      },
+      iconBase: app.dataset.icons,
+      catalogs: data.catalogs,
+      totalPeople: data.totalPeople
+    });
+  }).then(function (ctx) {
+    state.ctx = ctx;
+    var p = params();
+    if (form && !p.id && (p.member || p.contributor || p.q)) form.q.value = p.member || p.contributor || p.q;
+    show(p);
+  }).catch(function (err) {
+    setStatus('missing', 'Could not load contribution data: ' + esc(err.message));
+  });
+})();
diff --git a/assets/js/certificate/runtime.js b/assets/js/certificate/runtime.js
new file mode 100644
index 000000000000..ba3096a7c750
--- /dev/null
+++ b/assets/js/certificate/runtime.js
@@ -0,0 +1,154 @@
+/*
+ * SkyWalking certificate runtime — shared by every certificate design.
+ *
+ * A design registers itself as SWCert.designs[id] = { id, title, render(person, ctx) } and
+ * returns a complete, self-contained SVG string (viewBox 0 0 1123 794, A4 landscape).
+ * The runtime supplies ctx: inlined assets (data: URIs, so the exported PNG has no external
+ * references), a QR encoder, text measurement for auto-fit, and escaping.
+ */
+(function () {
+  'use strict';
+
+  var SWCert = window.SWCert = window.SWCert || {};
+  SWCert.designs = SWCert.designs || {};
+  SWCert.WIDTH = 1123;
+  SWCert.HEIGHT = 794;
+
+  var FONTS = {
+    sans: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
+    mono: 'ui-monospace, "SF Mono", Menlo, Consolas, monospace'
+  };
+
+  function toDataURI(url) {
+    return fetch(url).then(function (r) {
+      if (!r.ok) throw new Error(url + ' ' + r.status);
+      return r.blob();
+    }).then(function (blob) {
+      return new Promise(function (resolve) {
+        var fr = new FileReader();
+        fr.onload = function () { resolve(fr.result); };
+        fr.readAsDataURL(blob);
+      });
+    });
+  }
+
+  function escapeXml(s) {
+    return String(s == null ? '' : s)
+      .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
+      .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
+  }
+
+  var measureCtx = null;
+  function measure(text, font) {
+    measureCtx = measureCtx || document.createElement('canvas').getContext('2d');
+    measureCtx.font = font;
+    return measureCtx.measureText(String(text)).width;
+  }
+
+  // Largest font size in [min, max] at which text fits maxWidth. fontFor(size) returns a CSS font.
+  function fit(text, fontFor, maxWidth, max, min) {
+    for (var size = max; size > min; size -= 1) {
+      if (measure(text, fontFor(size)) <= maxWidth) return size;
+    }
+    return min;
+  }
+
+  // QR modules for text. path(x, y, size) returns one SVG path covering every dark module,
+  // drawn inside a size×size square (no quiet zone; callers leave >= 4 modules of margin).
+  function qr(text) {
+    var q = window.qrcode(0, 'M');
+    q.addData(text);
+    q.make();
+    var n = q.getModuleCount();
+    return {
+      size: n,
+      isDark: function (r, c) { return q.isDark(r, c); },
+      path: function (x, y, size) {
+        // One subpath per horizontal run of dark modules keeps the SVG small.
+        var cell = size / n, d = [];
+        for (var r = 0; r < n; r++) {
+          for (var c = 0; c < n; c++) {
+            if (!q.isDark(r, c)) continue;
+            var run = 1;
+            while (c + run < n && q.isDark(r, c + run)) run++;
+            var w = run * cell;
+            d.push('M' + (x + c * cell).toFixed(2) + ' ' + (y + r * cell).toFixed(2) +
+              'h' + w.toFixed(2) + 'v' + cell.toFixed(2) + 'h-' + w.toFixed(2) + 'z');
+            c += run - 1;
+          }
+        }
+        return d.join('');
+      }
+    };
+  }
+
+  SWCert.fonts = FONTS;
+
+  /*
+   * opts: { files: { skywalkingLogo, skywalkingLogoWhite, asfLogo, asfOakleaf } (URLs),
+   *         iconBase: URL prefix of the per-project icons (<icon>.svg), catalogs, totalPeople }
+   * Resolves to the ctx object passed to every design's render().
+   */
+  SWCert.buildContext = function (opts) {
+    var files = opts.files;
+    var iconBase = opts.iconBase.replace(/\/?$/, '/');
+    var icons = {};
+    (opts.catalogs || []).forEach(function (c) {
+      c.repos.forEach(function (r) { if (r.icon) icons[r.icon] = true; });
+    });
+    var assets = { projectIcons: {} };
+    var jobs = Object.keys(files).map(function (k) {
+      return toDataURI(files[k]).then(function (u) { assets[k] = u; });
+    }).concat(Object.keys(icons).map(function (k) {
+      return toDataURI(iconBase + k + '.svg')
+        .then(function (u) { assets.projectIcons[k] = u; })
+        .catch(function () { /* missing icon: designs fall back to a glyph */ });
+    }));
+    return Promise.all(jobs).then(function () {
+      return {
+        width: SWCert.WIDTH, height: SWCert.HEIGHT,
+        fonts: FONTS,
+        assets: assets,
+        catalogs: opts.catalogs || [],
+        totalPeople: opts.totalPeople || 0,
+        qr: qr, measure: measure, fit: fit, escape: escapeXml
+      };
+    });
+  };
+
+  SWCert.render = function (designId, person, ctx) {
+    var design = SWCert.designs[designId];
+    if (!design) throw new Error('Unknown certificate design: ' + designId);
+    return design.render(person, ctx);
+  };
+
+  function slug(person) {
+    return String(person.login || person.apacheId || 'certificate').replace(/[^A-Za-z0-9_-]/g, '');
+  }
+
+  // Rasterise an SVG string to PNG at the given scale and download it.
+  SWCert.downloadPNG = function (svg, person, scale) {
+    scale = scale || 2;
+    return new Promise(function (resolve, reject) {
+      var img = new Image();
+      img.onload = function () {
+        var canvas = document.createElement('canvas');
+        canvas.width = SWCert.WIDTH * scale;
+        canvas.height = SWCert.HEIGHT * scale;
+        var g = canvas.getContext('2d');
+        g.drawImage(img, 0, 0, canvas.width, canvas.height);
+        canvas.toBlob(function (blob) {
+          var a = document.createElement('a');
+          a.href = URL.createObjectURL(blob);
+          a.download = 'apache-skywalking-certificate-' + slug(person) + '.png';
+          document.body.appendChild(a);
+          a.click();
+          setTimeout(function () { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
+          resolve({ width: canvas.width, height: canvas.height, bytes: blob.size });
+        }, 'image/png');
+      };
+      img.onerror = function () { reject(new Error('SVG failed to rasterise')); };
+      img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
+    });
+  };
+})();
diff --git a/assets/scss/_custom_certificate.scss b/assets/scss/_custom_certificate.scss
new file mode 100644
index 000000000000..5a74d77a5edd
--- /dev/null
+++ b/assets/scss/_custom_certificate.scss
@@ -0,0 +1,105 @@
+// =====================================================================
+// /certificate page — light theme, consistent with team/contributors.
+// The certificate itself is an inline SVG drawn by static/js/certificate/.
+// (uses $ink/$body/$muted/$line/$brand/$brand-grad from _custom_home.scss)
+// =====================================================================
+.td-certificate {
+  background: #fff;
+
+  .eyebrow {
+    display: inline-block; font-size: 12px; font-weight: 700;
+    letter-spacing: .14em; text-transform: uppercase; color: $brand; margin-bottom: 12px;
+  }
+
+  .cert-hero {
+    padding: calc(4rem + 30px) 0 28px;
+    background:
+      radial-gradient(800px 380px at 12% 0%, rgba(55,136,208,.10), transparent 60%),
+      radial-gradient(640px 340px at 100% 0%, rgba(232,78,161,.06), transparent 55%),
+      linear-gradient(180deg, #f7faff 0%, #fff 70%);
+    border-bottom: 1px solid $line;
+    h1 { font-size: 40px; font-weight: 800; color: $ink; letter-spacing: -.8px; margin: 0 0 12px; border: 0; padding: 0; }
+    p  { font-size: 16px; color: $body; line-height: 1.6; margin: 0; max-width: 720px; }
+  }
+
+  .cert-search {
+    display: flex; gap: 8px; margin-top: 22px; max-width: 520px;
+    input {
+      flex: 1; min-width: 0; min-height: 44px; padding: 10px 14px;
+      border: 1px solid #c4d3e4; border-radius: 8px; background: #fff; color: $ink;
+      font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 14px;
+      &:focus { outline: none; border-color: $brand; box-shadow: 0 0 0 3px rgba(55,136,208,.15); }
+    }
+    button {
+      min-height: 44px; padding: 0 18px; border: 0; border-radius: 8px; cursor: pointer;
+      background: $brand-grad; color: #fff; font-weight: 700; font-size: 14px; white-space: nowrap;
+      &:hover { filter: brightness(1.05); }
+    }
+  }
+
+  .cert-wrap { max-width: 1200px; padding-top: 28px; padding-bottom: 64px; }
+
+  .cert-status {
+    display: flex; align-items: flex-start; gap: 10px;
+    padding: 12px 16px; border-radius: 10px; font-size: 14.5px; line-height: 1.55;
+    border: 1px solid $line; background: #f7faff; color: $body;
+    code { font-size: .95em; color: $ink; background: transparent; }
+    &.issued   { border-color: #d6e4f5; background: #eef5fd; color: #2c4a6b; }
+    &.verified { border-color: #bfe3cf; background: #f0faf4; color: #24543a; }
+    &.missing  { border-color: #f1d0c4; background: #fdf5f2; color: #7a3a22; }
+    .tick {
+      flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center;
+      width: 20px; height: 20px; margin-top: 1px; border-radius: 50%;
+      background: #2f9e62; color: #fff; font-size: 12px; font-weight: 800;
+    }
+  }
+
+  // Verification record: drawn like the certificate, with the VERIFIED stamp.
+  .cert-verify { margin-top: 20px; }
+  .cert-verify-today { margin: 16px 2px 0; font-size: 14.5px; color: $body; line-height: 1.6; a { color: $brand; font-weight: 600; white-space: nowrap; } }
+
+  .cert-toolbar {
+    display: flex; flex-wrap: wrap; align-items: center; gap: 12px;
+    margin: 20px 0 14px;
+  }
+  .cert-actions {
+    display: inline-flex; flex-wrap: wrap; gap: 8px;
+    button {
+      appearance: none; min-height: 38px; padding: 0 14px; border-radius: 8px; cursor: pointer;
+      border: 1px solid #c4d3e4; background: #fff; color: $ink; font-size: 13.5px; font-weight: 600;
+      &:hover { border-color: $brand; color: $brand; }
+      &.cert-png { background: $brand-grad; border-color: transparent; color: #fff; &:hover { color: #fff; filter: brightness(1.05); } }
+    }
+    .cert-copy .done { display: none; }
+    .cert-copy.copied { .label { display: none; } .done { display: inline; color: #2f9e62; } }
+  }
+
+  .cert-stage {
+    padding: clamp(10px, 2.4vw, 28px); border: 1px solid $line; border-radius: 16px;
+    background: repeating-linear-gradient(45deg, #f7f9fc, #f7f9fc 10px, #f3f6fa 10px, #f3f6fa 20px);
+  }
+  .cert-canvas svg {
+    display: block; width: 100%; height: auto;
+    border-radius: 10px; box-shadow: 0 24px 48px -28px rgba(17,24,39,.45);
+  }
+
+  .cert-note { margin: 18px 2px 0; font-size: 13px; color: $muted; line-height: 1.6; a { color: $brand; } }
+
+  @media (max-width: 575px) {
+    .cert-hero h1 { font-size: 30px; }
+    .cert-search { flex-direction: column; }
+  }
+}
+
+// Print / Save as PDF: the certificate alone, one A4 landscape page. The @page size is set
+// in layouts/certificate/baseof.html so it applies to this page only.
+@media print {
+  body.td-certificate {
+    > :not(.td-outer) { display: none !important; }   // header, widgets injected into <body> (Ask AI)
+    header, footer, .cert-hero, .cert-status, .cert-toolbar, .cert-note, .cert-verify-today, .td-sidebar, #popup { display: none !important; }
+    .cert-verify { margin: 0; }
+    .td-outer, .td-main, .cert-wrap { padding: 0 !important; margin: 0 !important; max-width: none !important; }
+    .cert-stage { padding: 0; border: 0; background: none; }
+    .cert-canvas svg { width: 297mm; height: 210mm; border-radius: 0; box-shadow: none; }
+  }
+}
diff --git a/assets/scss/_custom_contributors.scss b/assets/scss/_custom_contributors.scss
index 6a2bc456ea92..45475aa145d3 100644
--- a/assets/scss/_custom_contributors.scss
+++ b/assets/scss/_custom_contributors.scss
@@ -29,6 +29,12 @@
           .icon-github { font-size: .9em; } }
     }
     p { font-size: 15px; color: $body; line-height: 1.6; margin: 0; a { color: $brand; } }
+    .cert-cta {
+      display: inline-flex; align-items: center; margin-top: 16px; padding: 9px 16px; border-radius: 8px;
+      background: $brand-grad; color: #fff; font-size: 14px; font-weight: 700; text-decoration: none;
+      &:hover { filter: brightness(1.05); color: #fff; }
+      &[hidden] { display: none; }
+    }
   }
 
   .contrib-wrap { max-width: 980px; padding-top: 28px; padding-bottom: 64px; }
diff --git a/assets/scss/_custom_team.scss b/assets/scss/_custom_team.scss
index 8983a30f78d0..df8c6c6c04d9 100644
--- a/assets/scss/_custom_team.scss
+++ b/assets/scss/_custom_team.scss
@@ -23,6 +23,29 @@ $link-hook: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' vi
     p  { font-size: 16px; color: $body; line-height: 1.6; margin: 0; max-width: 680px; a { color: $brand; font-weight: 600; } }
   }
 
+  // The hero text on the left, the certificate badge on the right.
+  .team-hero-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 32px; }
+  .team-hero-text { min-width: 0; }
+  .cert-badge {
+    flex: 0 0 auto; display: flex; align-items: center; gap: 14px; margin-top: 18px;
+    padding: 14px 18px 14px 14px; border: 1px solid #d6e4f5; border-radius: 16px;
+    background: linear-gradient(180deg, #fff 0%, #f5f9fe 100%); color: $ink; text-decoration: none;
+    box-shadow: 0 18px 36px -26px rgba(17,24,39,.45);
+    transition: transform .18s ease, box-shadow .18s ease, border-color .18s ease;
+    &:hover { transform: translateY(-2px); border-color: #b9d3ee; box-shadow: 0 22px 40px -24px rgba(17,24,39,.5); color: $ink; text-decoration: none; }
+    .cert-badge-seal { flex: 0 0 auto; display: block; filter: drop-shadow(0 6px 10px rgba(44,108,176,.28)); }
+    .cert-badge-text { display: flex; flex-direction: column; gap: 2px; }
+    .cert-badge-kicker { font-size: 10.5px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; color: $brand; }
+    strong { font-size: 17px; font-weight: 800; letter-spacing: -.2px; color: $ink; }
+    .cert-badge-sub { font-size: 12.5px; color: $body; }
+    .cert-badge-arrow { margin-left: 4px; font-size: 18px; font-weight: 700; color: $brand; transition: transform .18s ease; }
+    &:hover .cert-badge-arrow { transform: translateX(3px); }
+  }
+  @media (max-width: 991px) {
+    .team-hero-row { flex-direction: column; gap: 0; }
+    .cert-badge { margin-top: 20px; }
+  }
+
   .team-wrap { max-width: 1200px; padding-top: 32px; padding-bottom: 64px; }
 
   // --- Members section (data-driven from data/committee.yml) ---
diff --git a/content/certificate/_index.md b/content/certificate/_index.md
new file mode 100644
index 000000000000..0ef61b03ea86
--- /dev/null
+++ b/content/certificate/_index.md
@@ -0,0 +1,7 @@
+---
+title: Certificate of Contribution
+linkTitle: Certificate
+description: Get an Apache SkyWalking Certificate of Contribution, drawn from the project's public contribution data and verifiable by QR code.
+---
+
+{{< certificate >}}
diff --git a/data/repositories.yml b/data/repositories.yml
new file mode 100644
index 000000000000..6b2196b19776
--- /dev/null
+++ b/data/repositories.yml
@@ -0,0 +1,92 @@
+# Every repository whose contributors this site counts, grouped into the contribution
+# categories of the Certificate of Contribution (/certificate/).
+#
+# Each repository listed in data/projects.yml, the Showcase (data/get-started.yml) and the
+# Website (package.json) must appear here exactly once, as owner/repo. A new repository is
+# added to its category here in the same change that adds it to the site;
+# scripts/project-config.js rejects the configuration otherwise, which fails the build.
+#
+# Category order is the order of the vertices of the certificate's contribution polygon,
+# clockwise from the top. Archived and retired repositories stay in their category.
+categories:
+  - id: protocol
+    name: Protocol
+    repos:
+      - apache/skywalking-data-collect-protocol
+      - apache/skywalking-query-protocol
+      - apache/skywalking-goapi
+
+  - id: main
+    name: Main
+    repos:
+      - apache/skywalking
+      - apache/skywalking-graalvm-distro
+      - apache/skywalking-ai-sessionizer
+
+  - id: agent
+    name: Agent
+    repos:
+      - apache/skywalking-java
+      - apache/skywalking-python
+      - apache/skywalking-nodejs
+      - apache/skywalking-go
+      - apache/skywalking-rust
+      - apache/skywalking-php
+      - apache/skywalking-client-js
+      - apache/skywalking-ruby
+      - apache/skywalking-nginx-lua
+      - apache/skywalking-kong
+      - apache/skywalking-satellite
+      - apache/skywalking-kubernetes-event-exporter
+      - apache/skywalking-rover
+
+  - id: database
+    name: Database
+    repos:
+      - apache/skywalking-banyandb
+      - apache/skywalking-banyandb-client-proto
+      - apache/skywalking-banyandb-helm
+      - apache/skywalking-banyandb-java-client
+
+  - id: website
+    name: Blogs & Website
+    repos:
+      - apache/skywalking-website
+
+  - id: tools
+    name: Tools
+    repos:
+      - apache/skywalking-eyes
+      - apache/skywalking-infra-e2e
+      - apache/skywalking-helm
+      - apache/skywalking-swck
+      - apache/skywalking-cli
+      - apache/skywalking-mcp
+      - apache/skywalking-agent-test-tool
+      - apache/skywalking-showcase
+      - apache/skywalking-docker
+      - apache/skywalking-oal-tool
+
+  - id: visualization
+    name: Visualization
+    repos:
+      - apache/skywalking-horizon-ui
+      - apache/skywalking-booster-ui
+      - apache/skywalking-rocketbot-ui
+      - apache/skywalking-ui
+      - apache/skywalking-grafana-plugins
+
+  - id: community
+    name: Community Projects
+    repos:
+      - SkyAPM/SkyAPM-dotnet
+      - SkyAPM/cpp2sky
+      - SkyAPM/java-plugin-extensions
+      - SkyAPM/uranus
+      - SkyAPM/document-cn-translation-of-skywalking
+      - SkyAPM/transporter-plugin-for-skywalking
+      - SkyAPM/go2sky
+      - SkyAPM/go2sky-plugins
+      - SkyAPM/SkyAPM-php-sdk
+      - SkyAPM/SkyAPM-nodejs
+      - sourceplusplus/SourceMarker
diff --git a/layouts/certificate/baseof.html b/layouts/certificate/baseof.html
new file mode 100644
index 000000000000..326d446bbdfc
--- /dev/null
+++ b/layouts/certificate/baseof.html
@@ -0,0 +1,23 @@
+<!doctype html>
+<html lang="{{ partial "seo/lang.html" . }}" class="no-js">
+  <head>
+    {{ partial "head.html" . }}
+    {{- /* Certificates print on one A4 landscape page; scoped here so other pages keep their print layout. */}}
+    <style media="print">@page { size: A4 landscape; margin: 0; }</style>
+  </head>
+  <body class="td-{{ .Kind }} td-certificate">
+    <header>
+      {{ partial "navbar.html" . }}
+    </header>
+    <div class="container-fluid td-outer">
+      <main class="td-main" role="main">
+        {{ block "main" . }}{{ end }}
+      </main>
+      {{ partial "footer.html" . }}
+    </div>
+    {{ partial "event-popup.html" . }}
+    {{ partial "lightbox.html" . }}
+    {{ partial "sidebar-skywalking.html" . }}
+    {{ partial "scripts.html" . }}
+  </body>
+</html>
diff --git a/layouts/contributors/list.html b/layouts/contributors/list.html
new file mode 100644
index 000000000000..cf0cef7cf743
--- /dev/null
+++ b/layouts/contributors/list.html
@@ -0,0 +1,4 @@
+{{ define "main" }}
+{{- /* The contributors shortcodes carry their own full-width hero and container. */ -}}
+{{ .Content }}
+{{ end }}
diff --git a/layouts/shortcodes/certificate.html b/layouts/shortcodes/certificate.html
new file mode 100644
index 000000000000..05266782b0b4
--- /dev/null
+++ b/layouts/shortcodes/certificate.html
@@ -0,0 +1,60 @@
+{{- /* /certificate — Certificate of Contribution, rendered in the visitor's browser from
+       assets/certificate/people.json, which scripts/certificate.js builds from data/team.yml,
+       data/committee.yml and data/repositories.yml. The same script writes the day's issued
+       certificate codes to certificate/verify/<date>.txt, the only certificate data the site
+       keeps. Drawing, model and verification live in assets/js/certificate/. */ -}}
+{{- $people := resources.Get "certificate/people.json" -}}
+{{- if not $people -}}
+  {{- warnf "certificate: assets/certificate/people.json is missing; run npm run certificate" -}}
+{{- end -}}
+<section class="cert-hero">
+  <div class="container">
+    <span class="eyebrow">CERTIFICATE</span>
+    <h1>Certificate of Contribution</h1>
+    <p>Every PMC member, committer and contributor listed on this site can get an Apache SkyWalking
+       certificate. It is drawn in your browser from the project's public contribution data, and its
+       QR code links back here, where anyone can verify it.</p>
+    <form class="cert-search" role="search" autocomplete="off">
+      <input type="search" name="q" placeholder="GitHub login or Apache ID" aria-label="GitHub login or Apache ID" spellcheck="false">
+      <button type="submit">Get certificate</button>
+    </form>
+  </div>
+</section>
+
+<div class="container cert-wrap" id="cert-app"
+     data-people="{{ with $people }}{{ (. | fingerprint).RelPermalink }}{{ end }}"
+     data-verify="{{ "certificate/verify/" | relURL }}"
+     data-base="{{ site.BaseURL }}"
+     data-logo="{{ "images/logo.svg" | relURL }}"
+     data-logo-white="{{ "favicons/logo.svg" | relURL }}"
+     data-asf-logo="{{ "images/certificate/asf-logo-horizontal-color.svg" | relURL }}"
+     data-asf-leaf="{{ "images/asf-oakleaf.svg" | relURL }}"
+     data-icons="{{ "images/project/" | relURL }}">
+  <div class="cert-status idle" role="status" aria-live="polite"></div>
+  <div class="cert-verify" hidden>
+    <div class="cert-stage"><div class="cert-canvas cert-verify-canvas"></div></div>
+    <p class="cert-verify-today"></p>
+  </div>
+  <div class="cert-result" hidden>
+    <div class="cert-toolbar">
+      <div class="cert-actions">
+        <button type="button" class="cert-png">Download PNG</button>
+        <button type="button" class="cert-print">Print / Save PDF</button>
+        <button type="button" class="cert-copy"><span class="label">Copy verify link</span><span class="done">Copied</span></button>
+      </div>
+    </div>
+    <div class="cert-stage"><div class="cert-canvas"></div></div>
+  </div>
+  <p class="cert-note">Contributions count the commits GitHub attributes to each account on every
+     repository listed on this site, refreshed daily. Members are listed on the
+     <a href="{{ "team/" | relURL }}">team page</a>; every contributor is searchable on the
+     <a href="{{ "contributors/" | relURL }}">contributors page</a>.</p>
+</div>
+
+{{- /* Designs register themselves on SWCert.designs, so the runtime goes first and the controller last. */ -}}
+{{- $parts := slice (resources.Get "js/certificate/runtime.js") (resources.Get "js/certificate/model.js") -}}
+{{- $parts = $parts | append (resources.Match "js/certificate/designs/*.js") -}}
+{{- $parts = $parts | append (resources.Get "js/certificate/page.js") -}}
+{{- $certificateScript := $parts | resources.Concat "js/certificate.js" | minify | fingerprint }}
+<script src="{{ "js/qrcode-generator-1.4.4.js" | relURL }}"></script>
+<script src="{{ $certificateScript.RelPermalink }}" integrity="{{ $certificateScript.Data.Integrity }}"></script>
diff --git a/layouts/shortcodes/contributors-search.html b/layouts/shortcodes/contributors-search.html
index 07c3c9ab70b1..79624f788b69 100644
--- a/layouts/shortcodes/contributors-search.html
+++ b/layouts/shortcodes/contributors-search.html
@@ -8,6 +8,7 @@
     <h1 class="page-title">Contributions by <span class="current-contributor"></span></h1>
     <p>Per-project contribution counts and rankings. Search a different handle on the
        <a href="/team/">team page</a>.</p>
+    <a class="cert-cta" href="{{ "certificate/" | relURL }}" hidden>Get your Certificate of Contribution &rarr;</a>
   </div>
 </section>
 
diff --git a/layouts/team/list.html b/layouts/team/list.html
index eb7cdfbe5943..ffe833332962 100644
--- a/layouts/team/list.html
+++ b/layouts/team/list.html
@@ -1,12 +1,32 @@
 {{ define "main" }}
 <section class="team-hero">
-  <div class="container">
-    <span class="eyebrow">TEAM</span>
-    <h1>The people behind SkyWalking</h1>
-    <p>The project is run by its Members (PMC &amp; Committers) and grown by
-       <a href="/contributors/">{{ .Site.Data.team.totalCount | default "1,000+" }} contributors</a>.
-       Members have direct access to the source and actively evolve the codebase; contributors
-       improve the project through patches and suggestions.</p>
+  <div class="container team-hero-row">
+    <div class="team-hero-text">
+      <span class="eyebrow">TEAM</span>
+      <h1>The people behind SkyWalking</h1>
+      <p>The project is run by its Members (PMC &amp; Committers) and grown by
+         <a href="/contributors/">{{ .Site.Data.team.totalCount | default "1,000+" }} contributors</a>.
+         Members have direct access to the source and actively evolve the codebase; contributors
+         improve the project through patches and suggestions.</p>
+    </div>
+    <a class="cert-badge" href="{{ "certificate/" | relURL }}">
+      <svg class="cert-badge-seal" viewBox="0 0 64 72" width="56" height="63" aria-hidden="true">
+        <defs><linearGradient id="cert-badge-grad" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#479EEB"/><stop offset="1" stop-color="#2c6cb0"/></linearGradient></defs>
+        <polygon points="22,44 15,69 23,64 28,70 31,49" fill="#245f9d"/>
+        <polygon points="42,44 49,69 41,64 36,70 33,49" fill="#245f9d"/>
+        <polygon points="32.0,3.0 35.2,5.6 39.0,3.9 41.4,7.3 45.5,6.6 47.0,10.5 51.1,10.9 51.5,15.0 55.4,16.5 54.7,20.6 58.1,23.0 56.4,26.8 59.0,30.0 56.4,33.2 58.1,37.0 54.7,39.4 55.4,43.5 51.5,45.0 51.1,49.1 47.0,49.5 45.5,53.4 41.4,52.7 39.0,56.1 35.2,54.4 32.0,57.0 28.8,54.4 25.0,56.1 22.6,52.7 18.5,53.4 17.0,49.5 12.9,49.1 12.5,45.0 8.6,43.5 9.3,39.4 5.9,37.0 7.6,33.2 5.0,30.0 7.6,26.8 5.9,23.0 9.3,20.6 8.6,16.5 12.5,15.0 12.9,10.9 17.0,10.5 18.5,6.6 22.6,7.3 25.0,3.9 28.8,5.6" fill [...]
+        <circle cx="32" cy="30" r="20" fill="none" stroke="#fff" stroke-opacity=".55"/>
+        <polygon points="32.0,15.0 42.6,19.4 47.0,30.0 42.6,40.6 32.0,45.0 21.4,40.6 17.0,30.0 21.4,19.4" fill="none" stroke="#fff" stroke-opacity=".35"/>
+        <polygon points="32.0,17.0 38.7,23.3 44.0,30.0 37.3,35.3 32.0,41.0 26.0,36.0 19.5,30.0 24.9,22.9" fill="#fff" fill-opacity=".92"/>
+        <circle cx="32" cy="30" r="2.2" fill="#2c6cb0"/>
+      </svg>
+      <span class="cert-badge-text">
+        <span class="cert-badge-kicker">Certificate of Contribution</span>
+        <strong>Get your certificate</strong>
+        <span class="cert-badge-sub">For every PMC member, committer and contributor</span>
+      </span>
+      <span class="cert-badge-arrow" aria-hidden="true">&rarr;</span>
+    </a>
   </div>
 </section>
 
diff --git a/package.json b/package.json
index dd72491726ff..c156ace05255 100644
--- a/package.json
+++ b/package.json
@@ -4,15 +4,16 @@
   "description": "SkyWalking Website",
   "main": "none.js",
   "scripts": {
-    "build": "npm install && npm run stars && hugo",
-    "build-with-docs": "npm install && npm run docs && npm run team && npm run stars && hugo && npm run cp",
-    "build-preview": "npm install && npm run docs && npm run team && npm run stars && hugo && npm run cp",
+    "build": "npm install && npm run stars && npm run certificate && hugo",
+    "build-with-docs": "npm install && npm run docs && npm run team && npm run certificate && npm run stars && hugo && npm run cp",
+    "build-preview": "npm install && npm run docs && npm run team && npm run certificate && npm run stars && hugo && npm run cp",
     "docs": "node ./docs.js",
     "team": "node ./scripts/team.js",
+    "certificate": "node ./scripts/certificate.js",
     "stars": "node ./scripts/stars.js",
     "cp": "cp ./.htaccess ./public && cp ./.asf.yaml ./public",
     "validate-projects": "node ./scripts/project-config.js",
-    "test": "node --test ./scripts/project-config.test.js"
+    "test": "node --test ./scripts/project-config.test.js ./scripts/certificate.test.js"
   },
   "repository": {
     "type": "git",
@@ -25,6 +26,7 @@
   "homepage": "skywalking.apache.org",
   "dependencies": {
     "axios": "^0.21.1",
+    "js-yaml": "^3.14.1",
     "yamljs": "^0.3.0"
   },
   "devDependencies": {
diff --git a/scripts/certificate.js b/scripts/certificate.js
new file mode 100644
index 000000000000..e7ec348862bb
--- /dev/null
+++ b/scripts/certificate.js
@@ -0,0 +1,104 @@
+// Builds the data of the Certificate of Contribution (/certificate/):
+//
+// - assets/certificate/people.json: today's contributors per repository, grouped into the
+//   contribution categories of data/repositories.yml, plus the PMC and committers. The page
+//   draws certificates from it and it is rebuilt every time.
+// - static/certificate/verify/<date>.txt: the code of every certificate that can be issued
+//   today, after a header line naming the day's categories in polygon order, so a verified
+//   polygon is labelled as it was issued. This is the only certificate data the site keeps:
+//   the deploy carries certificate/verify/ forward on asf-site, so a certificate stays
+//   verifiable for good.
+//
+// Run after `npm run team`; without data/team.yml only members are listed.
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+// js-yaml, not yamljs: yamljs reads an unquoted login such as 0xAlanYin as a hex number.
+const yaml = require('js-yaml');
+const {loadProjectConfig} = require('./project-config');
+const model = require('../assets/js/certificate/model');
+
+const root = path.join(__dirname, '..');
+
+function buildPeople(team, committee, catalog, date) {
+  const categoryOf = {};
+  catalog.categories.forEach(category => category.repos.forEach(repo => { categoryOf[repo.toLowerCase()] = category.id; }));
+  const buckets = {};
+  for (const group of team.projects || []) {
+    for (const item of group.list || []) {
+      if (!item.repo) continue;
+      const category = categoryOf[`${item.user}/${item.repo}`.toLowerCase()];
+      if (!category) throw new Error(`certificate: ${item.user}/${item.repo} is not listed in data/repositories.yml`);
+      const contributors = item.contributors || [];
+      // Only GitHub accounts with a commit count get a certificate: anonymous commits, bots and
+      // name-only acknowledgements (projects.yml extraContributors, e.g. "Adrian Cole") cannot be
+      // verified. Ranks keep their position in the full list.
+      const rows = contributors
+        .map((c, i) => ({l: String(c.login), n: c.contributions, r: i + 1, type: c.type}))
+        .filter(row => row.type !== 'Anonymous' && !row.l.endsWith('[bot]') && Number.isInteger(row.n) && row.n > 0)
+        .map(({l, n, r}) => ({l, n, r}));
+      (buckets[category] = buckets[category] || []).push({
+        name: item.name,
+        label: item.name.replace(/^\((Archived|Retired|Experimental|outdated)\)\s*/, ''),
+        archived: /^\((Archived|Retired|outdated)\)/.test(item.name),
+        icon: item.icon || null,
+        of: contributors.length,
+        contributors: rows,
+      });
+    }
+  }
+  return {
+    generated: date,
+    totalPeople: team.totalCount || 0,
+    catalogs: catalog.categories.map(c => ({id: c.id, name: c.name, repos: buckets[c.id] || []})),
+    members: {pmc: committee.pmc || [], committer: committee.committer || []},
+  };
+}
+
+function codeFor(date, facts) {
+  const digest = crypto.createHash('sha256').update(model.canonical(date, facts), 'utf8').digest();
+  return model.encode(digest);
+}
+
+function issuedCodes(people, date) {
+  const idx = model.index(people);
+  return model.everyone(idx)
+    .map(query => codeFor(date, model.facts(people, idx, model.resolve(idx, query))))
+    .sort();
+}
+
+// The day file: a '#' header with the categories in order, then one code per line.
+function dayFile(people, date) {
+  const header = '# ' + JSON.stringify({categories: people.catalogs.map(c => c.name)});
+  return [header, ...issuedCodes(people, date)].join('\n') + '\n';
+}
+
+function main() {
+  const date = new Date().toISOString().slice(0, 10);
+  const config = loadProjectConfig(root);
+  const teamFile = path.join(root, 'data/team.yml');
+  const load = file => yaml.safeLoad(fs.readFileSync(file, 'utf8'));
+  const team = fs.existsSync(teamFile) ? load(teamFile) : {};
+  const committee = load(path.join(root, 'data/committee.yml'));
+  const people = buildPeople(team, committee, config.repositoryCatalog, date);
+
+  fs.mkdirSync(path.join(root, 'assets/certificate'), {recursive: true});
+  fs.writeFileSync(path.join(root, 'assets/certificate/people.json'), JSON.stringify(people));
+  const verifyDir = path.join(root, 'static/certificate/verify');
+  fs.mkdirSync(verifyDir, {recursive: true});
+  const day = dayFile(people, date);
+  fs.writeFileSync(path.join(verifyDir, `${date}.txt`), day);
+  const count = day.split('\n').filter(line => line && !line.startsWith('#')).length;
+  console.log(`certificate: ${count} certificate codes for ${date}${team.projects ? '' : ' (no data/team.yml: members only)'}.`);
+}
+
+module.exports = {buildPeople, codeFor, issuedCodes, dayFile};
+
+if (require.main === module) {
+  try {
+    main();
+  } catch (error) {
+    console.error(error.message);
+    process.exitCode = 1;
+  }
+}
diff --git a/scripts/certificate.test.js b/scripts/certificate.test.js
new file mode 100644
index 000000000000..f632407fb305
--- /dev/null
+++ b/scripts/certificate.test.js
@@ -0,0 +1,96 @@
+const assert = require('node:assert/strict');
+const test = require('node:test');
+const {buildPeople, codeFor, issuedCodes, dayFile} = require('./certificate');
+const model = require('../assets/js/certificate/model');
+
+const catalog = {categories: [
+  {id: 'main', name: 'Main', repos: ['apache/main']},
+  {id: 'agent', name: 'Agent', repos: ['apache/agent', 'SkyAPM/Old-Agent']},
+]};
+const committee = {pmc: [{name: 'Pat Lead (Project V.P.)', apacheId: 'pat', github: 'pat-gh'}], committer: [{name: 'Cy Commit', apacheId: 'cy'}]};
+
+function team() {
+  const person = (login, contributions, type = 'User') => ({login, contributions, type});
+  return {totalCount: 6, projects: [{type: 'Platform', list: [
+    {name: null, descriptionItem: 'Catalog note'},
+    {name: 'Main', user: 'apache', repo: 'main', icon: 'main', contributors: [
+      person('pat-gh', 50), person('someone**', 9, 'Anonymous'), person('ann', 5), person('dependabot[bot]', 4), person('0xAlanYin', 1),
+      // Appended by scripts/team.js from projects.yml extraContributors: a name, no count.
+      {login: 'Adrian Cole'}, {login: 'github-actions[bot]'},
+    ]},
+  ]}, {type: 'Agents', list: [
+    {name: 'Agent', user: 'apache', repo: 'agent', contributors: [person('ann', 7), person('pat-gh', 2)]},
+    {name: '(Archived) Old Agent', user: 'SkyAPM', repo: 'old-agent', contributors: [person('ann', 1)]},
+  ]}]};
+}
+
+test('people data keeps verifiable contributors, their ranks and the catalog order', () => {
+  const people = buildPeople(team(), committee, catalog, '2026-10-20');
+  assert.equal(people.generated, '2026-10-20');
+  assert.deepEqual(people.catalogs.map(c => c.id), ['main', 'agent']);
+  const main = people.catalogs[0].repos[0];
+  // Anonymous commits, bots and name-only extras are dropped; ranks keep their position.
+  assert.deepEqual(main.contributors, [{l: 'pat-gh', n: 50, r: 1}, {l: 'ann', n: 5, r: 3}, {l: '0xAlanYin', n: 1, r: 5}]);
+  assert.equal(main.of, 7);
+  const old = people.catalogs[1].repos[1];
+  assert.equal(old.label, 'Old Agent');
+  assert.equal(old.archived, true);
+  assert.equal(old.icon, null);
+});
+
+test('a repository missing from data/repositories.yml stops the build', () => {
+  const data = team();
+  data.projects[1].list.push({name: 'New', user: 'apache', repo: 'new', contributors: []});
+  assert.throws(() => buildPeople(data, committee, catalog, '2026-10-20'), /apache\/new is not listed in data\/repositories.yml/);
+});
+
+test('facts carry role, totals and the 10% rank band of every category', () => {
+  const people = buildPeople(team(), committee, catalog, '2026-10-20');
+  const idx = model.index(people);
+  const pat = model.facts(people, idx, model.resolve(idx, {contributor: 'pat-gh'}));
+  // A member's GitHub login resolves to the member.
+  assert.deepEqual(pat, {key: 'member:pat', role: 'p', total: 52, projects: 2, bands: 'a5'});
+  const ann = model.facts(people, idx, model.resolve(idx, {contributor: 'ANN'}));
+  // Main: ann is #2 of 3, band 10 - floor(1 * 10 / 3) = 7; Agent: #1 of 2, band 10.
+  assert.deepEqual(ann, {key: 'contributor:ann', role: 'x', total: 13, projects: 3, bands: '7a'});
+  // A member without GitHub data has no totals and empty bands.
+  const cy = model.facts(people, idx, model.resolve(idx, {member: 'cy'}));
+  assert.deepEqual(cy, {key: 'member:cy', role: 'c', total: null, projects: 0, bands: '00'});
+  assert.equal(model.canonical('2026-10-20', cy), 'v1|2026-10-20|member:cy|c||0|00');
+});
+
+test('codes are 80 bits of SHA-256 in Crockford base32, and change with any fact', () => {
+  assert.equal(model.encode(Uint8Array.from([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])), '000G40R40M30E209');
+  const facts = {key: 'member:wusheng', role: 'p', total: 8193, projects: 48, bands: 'aaaaaaaa'};
+  assert.equal(codeFor('2026-10-20', facts), 'Z1JQ11JK2CGHW9DZ');
+  assert.notEqual(codeFor('2026-10-20', {...facts, total: 8194}), 'Z1JQ11JK2CGHW9DZ');
+  assert.notEqual(codeFor('2026-10-21', facts), 'Z1JQ11JK2CGHW9DZ');
+});
+
+test('the day list holds one code for every member and every other contributor', () => {
+  const people = buildPeople(team(), committee, catalog, '2026-10-20');
+  const codes = issuedCodes(people, '2026-10-20');
+  // pat (member, also pat-gh), cy (member), ann and 0xAlanYin (contributors).
+  assert.equal(codes.length, 4);
+  assert.deepEqual(codes, [...codes].sort());
+  const idx = model.index(people);
+  assert.ok(codes.includes(codeFor('2026-10-20', model.facts(people, idx, model.resolve(idx, {contributor: '0xalanyin'})))));
+});
+
+test('the day file names the day\'s categories in order before the codes', () => {
+  const people = buildPeople(team(), committee, catalog, '2026-10-20');
+  const lines = dayFile(people, '2026-10-20').trim().split('\n');
+  assert.equal(lines[0], '# {"categories":["Main","Agent"]}');
+  assert.deepEqual(lines.slice(1), issuedCodes(people, '2026-10-20'));
+});
+
+test('logins and IDs that are names of JavaScript object members are ordinary keys', () => {
+  const data = team();
+  data.projects[0].list[1].contributors.push({login: 'constructor', contributions: 3, type: 'User'}, {login: '__proto__', contributions: 2, type: 'User'});
+  const people = buildPeople(data, committee, catalog, '2026-10-20');
+  const idx = model.index(people);
+  assert.equal(model.facts(people, idx, model.resolve(idx, {contributor: 'constructor'})).total, 3);
+  assert.equal(model.facts(people, idx, model.resolve(idx, {q: '__proto__'})).total, 2);
+  assert.equal(model.resolve(idx, {member: 'constructor'}), null);
+  assert.equal(model.resolve(idx, {q: 'toString'}), null);
+});
diff --git a/scripts/project-config.js b/scripts/project-config.js
index 19ad133f442e..46880a2322fb 100644
--- a/scripts/project-config.js
+++ b/scripts/project-config.js
@@ -129,6 +129,34 @@ function validateProjectConfig(config) {
   return config;
 }
 
+// data/repositories.yml groups every counted repository into the contribution categories of
+// the Certificate of Contribution. It lists each repository of the site exactly once, and
+// nothing else, so a repository added to the site cannot go uncategorised.
+function validateRepositoryCatalog(catalog, config) {
+  check(isObject(catalog) && Array.isArray(catalog.categories) && catalog.categories.length > 0, 'data/repositories.yml requires a categories array');
+  const ids = new Set();
+  const listed = new Set();
+  catalog.categories.forEach((category, index) => {
+    const where = `data/repositories.yml categories[${index}]`;
+    check(isObject(category) && /^[a-z][a-z0-9-]*$/.test(category.id || '') && isText(category.name), `${where} requires a lower-case id and a name`);
+    check(!ids.has(category.id), `data/repositories.yml has duplicate category id ${category.id}`);
+    ids.add(category.id);
+    check(Array.isArray(category.repos) && category.repos.length > 0, `${where}.repos must be a nonempty array`);
+    category.repos.forEach(entry => {
+      check(typeof entry === 'string' && /^[a-z0-9_-]+\/[a-z0-9_.-]+$/i.test(entry), `${where}.repos entry ${entry} must be owner/repo`);
+      check(!listed.has(entry.toLowerCase()), `data/repositories.yml lists ${entry} more than once`);
+      listed.add(entry.toLowerCase());
+    });
+  });
+  const expected = collectRepos(config).map(({user, repo}) => `${user}/${repo}`);
+  const missing = expected.filter(name => !listed.has(name.toLowerCase()));
+  check(!missing.length, `add ${missing.join(', ')} to a category in data/repositories.yml`);
+  const known = new Set(expected.map(name => name.toLowerCase()));
+  const stale = [...listed].filter(name => !known.has(name));
+  check(!stale.length, `data/repositories.yml lists ${stale.join(', ')}, which the site no longer includes`);
+  return catalog;
+}
+
 function websiteProject(packageInfo) {
   const repository = typeof packageInfo.repository === 'string' ? packageInfo.repository : packageInfo.repository.url;
   const url = new URL(repository.replace(/^git\+/, ''));
@@ -147,7 +175,9 @@ function loadProjectConfig(root = path.join(__dirname, '..')) {
   const projects = YAML.load(path.join(root, 'data/projects.yml'));
   const {showcase} = YAML.load(path.join(root, 'data/get-started.yml'));
   const packageInfo = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
-  return validateProjectConfig({...projects, showcase, website: websiteProject(packageInfo)});
+  const config = validateProjectConfig({...projects, showcase, website: websiteProject(packageInfo)});
+  config.repositoryCatalog = validateRepositoryCatalog(YAML.load(path.join(root, 'data/repositories.yml')), config);
+  return config;
 }
 
 function docsForProject(project) {
@@ -196,13 +226,13 @@ function collectRepos(config) {
     .map(({user, repo}) => ({user, repo}));
 }
 
-module.exports = {loadProjectConfig, validateProjectConfig, docsForProject, deriveDocsList, collectRepos, websiteProject};
+module.exports = {loadProjectConfig, validateProjectConfig, validateRepositoryCatalog, docsForProject, deriveDocsList, collectRepos, websiteProject};
 
 if (require.main === module) {
   try {
     const config = loadProjectConfig();
     const docs = deriveDocsList(config).flatMap(group => group.list).flatMap(project => project.docs || []);
-    console.log(`Validated ${config.catalogs.length} catalogs, ${collectRepos(config).length} repositories and ${docs.length} documentation entries.`);
+    console.log(`Validated ${config.catalogs.length} catalogs, ${collectRepos(config).length} repositories in ${config.repositoryCatalog.categories.length} contribution categories and ${docs.length} documentation entries.`);
   } catch (error) {
     console.error(error.message);
     process.exitCode = 1;
diff --git a/scripts/project-config.test.js b/scripts/project-config.test.js
index 8e3623428edc..e4b3464e3f4d 100644
--- a/scripts/project-config.test.js
+++ b/scripts/project-config.test.js
@@ -1,6 +1,6 @@
 const assert = require('node:assert/strict');
 const test = require('node:test');
-const {validateProjectConfig, docsForProject, deriveDocsList, collectRepos, websiteProject} = require('./project-config');
+const {loadProjectConfig, validateProjectConfig, validateRepositoryCatalog, docsForProject, deriveDocsList, collectRepos, websiteProject} = require('./project-config');
 
 function fixture() {
   return {
@@ -162,3 +162,26 @@ test('includes Showcase and package-derived Website in the team/stars repository
     description: 'This is the repository including all source codes of https://example.apache.org', user: 'apache', repo: 'example-website',
   });
 });
+
+test('the repository catalog lists every repository of the site exactly once', () => {
+  const config = validateProjectConfig(fixture());
+  const catalog = repos => ({categories: [{id: 'main', name: 'Main', repos}]});
+  validateRepositoryCatalog(catalog(['apache/example', 'apache/example-showcase']), config);
+  // Owner and repository names compare case-insensitively, as GitHub does.
+  validateRepositoryCatalog(catalog(['Apache/Example', 'apache/example-showcase']), config);
+  assert.throws(() => validateRepositoryCatalog(catalog(['apache/example']), config), /add apache\/example-showcase to a category/);
+  assert.throws(() => validateRepositoryCatalog(catalog(['apache/example', 'apache/example-showcase', 'apache/example']), config), /more than once/);
+  assert.throws(() => validateRepositoryCatalog(catalog(['apache/example', 'apache/example-showcase', 'apache/gone']), config), /apache\/gone, which the site no longer includes/);
+  assert.throws(() => validateRepositoryCatalog(catalog(['example', 'apache/example-showcase']), config), /must be owner\/repo/);
+  assert.throws(() => validateRepositoryCatalog({categories: [
+    {id: 'main', name: 'Main', repos: ['apache/example']},
+    {id: 'main', name: 'Again', repos: ['apache/example-showcase']},
+  ]}, config), /duplicate category id main/);
+  assert.throws(() => validateRepositoryCatalog({categories: [{id: 'Main', name: 'Main', repos: ['apache/example', 'apache/example-showcase']}]}, config), /lower-case id/);
+});
+
+test('the checked-in configuration catalogs every repository', () => {
+  const config = loadProjectConfig();
+  const listed = config.repositoryCatalog.categories.flatMap(category => category.repos).length;
+  assert.equal(listed, collectRepos(config).length);
+});
diff --git a/static/images/certificate/asf-logo-horizontal-color.svg b/static/images/certificate/asf-logo-horizontal-color.svg
new file mode 100644
index 000000000000..f1084dfb369f
--- /dev/null
+++ b/static/images/certificate/asf-logo-horizontal-color.svg
@@ -0,0 +1,43 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 525">
+  <defs>
+    <style>
+      .cls-1 {
+        fill: #7c297d;
+      }
+
+      .cls-2 {
+        fill: #f79a23;
+      }
+
+      .cls-3 {
+        fill: #dd552c;
+      }
+
+      .cls-4 {
+        fill: #d22128;
+      }
+    </style>
+  </defs>
+  <g>
+    <g>
+      <path class="cls-1" d="M458.8041825,181.7454822h49.0451573l87.4128103,220.0939304h-53.4548455l-14.7775045-38.0445002h-87.4060779l-15.0939249,38.0445002h-53.1384251l87.4128103-220.0939304ZM514.4537735,324.4914631l-31.1236462-80.4919562-30.8139581,80.4919562h61.9376043Z"/>
+      <path class="cls-1" d="M622.7166664,331.7220052h48.7354693c.3164204,21.0655178,16.0364536,33.3318564,40.2459781,33.3318564,20.7490974,0,33.9579648-8.4894912,33.9579648-23.2669957,0-15.4103452-14.1513961-24.5259448-45.2750423-31.4467989-51.5697879-10.6909691-76.0957328-32.0661749-76.0957328-68.2256176,0-39.9295577,31.7564869-64.7719229,82.3837461-64.7719229,52.189164,0,85.5210204,28.2960599,85.5210204,72.6285734h-48.1093609c0-19.8065687-13.8349757-31.7564869-36.7855511-31.7564869-21 [...]
+      <path class="cls-1" d="M852.9091193,181.7454822h137.7169167v45.2750423h-87.7224983v55.0234826h79.8591155v44.0228255h-79.8591155v75.7725801h-49.9944184v-220.0939304Z"/>
+    </g>
+    <g>
+      <path class="cls-3" d="M141.1077177,209.8881008c13.1869872-32.608129,27.9005344-65.1523006,43.6878912-93.9970469-23.5665852-17.5041056-46.1956909-55.2490163-55.4930639-71.9721694-3.3392448,3.7936783-5.5070609,8.1629723-6.4899838,11.7176522-8.7587851,31.6252061,22.4204029,69.8144518-2.6929393,55.86166-20.9241385-11.6267655-68.040478-37.0885068-86.0124818-11.7816095,20.1280383,25.862316,72.7733189,90.9304622,107.0005776,110.1715137Z"/>
+      <path class="cls-2" d="M184.7956089,115.8910539c15.2975784-27.9493439,31.5999598-52.4281623,48.5015202-70.0635487,0,0-16.8578001,24.4013964-40.9360438,73.180626,14.5822664,4.0158458,56.1124399,12.2562401,113.9601427-2.686207,1.4222086-10.5125619-5.645074-22.0753701-40.8636711-25.9464703-22.9926525-2.5246306,27.5975787-54.9023003-9.0516423-79.6739759-1.1832102-.8011494-2.3462234-1.508046-3.4856733-2.1408868-1.2286536-.4409688-2.5263137-.8449097-3.9114944-1.2050903-42.8025874-11.1689 [...]
+      <path class="cls-4" d="M106.6818547,303.8380213c9.6626029-28.9457315,21.2860022-61.4663399,34.425863-93.9499204-34.2272587-19.2410514-86.8725393-84.3091977-107.0005776-110.1715137-3.5933909,5.0559936-6.0490149,12.1081284-6.8669953,21.7522173-4.3894911,51.802054,49.0518897,90.152876,38.3979485,97.1578844-14.0924881,9.2670774-42.1411341-22.2605096-53.19565-2.2284073,16.0229889,20.5841549,48.6614135,57.7770131,94.2394114,87.4397396Z"/>
+      <path class="cls-3" d="M254.7564893,192.1180675c-27.0589909-9.6508213,28.2758628-35.5333343,46.2445004-62.9912168,2.2990969-3.5109196,4.6756159-8.0485224,5.3202382-12.8049265-57.8477027,14.942447-99.3778763,6.7020527-113.9601427,2.686207-12.4649429,25.2564046-26.8587035,57.0835812-41.8701571,95.4579665,15.6341958,6.6852219,79.4703224,31.3794754,169.6366631,31.50739,15.1595653-39.4852227-39.6905594-44.6994265-65.3711019-53.8554202Z"/>
+      <path class="cls-4" d="M117.1371916,309.0488589c15.9001236,4.9550083,68.5841152,19.7560761,124.5366618,17.5276688,7.5166669-20.3518889-20.585838-22.3379316-22.8748364-38.6605101-1.7722907-12.6298854,73.9565291,10.6135471,98.3225807-35.3111668,1.2320197-2.3226602,2.1930625-4.5140395,3.0059935-6.6313631-90.1663407-.1279146-154.0024672-24.8221682-169.6366631-31.50739-10.9703615,28.0435968-22.2554604,59.5173251-33.3537365,94.5827612Z"/>
+      <path class="cls-1" d="M117.1371916,309.0488589c-7.1447046,22.5701977-14.1934733,46.6821031-21.0352222,72.1640414-2.4270116,9.0348114-4.8254106,18.2379315-7.190148,27.6430221,53.1064464,17.531035,102.0085414.0403941,103.6663821-21.6377674.0134647-.1716749-.018514-.2995895-.0084154-.464532,1.2639984-22.9606739-33.150083-10.2331694-32.3354689-24.0378496.8179803-13.9056654,60.0811593-.0807882,78.4082944-29.8949926,1.4053777-2.2856322,2.3209771-4.3154353,3.0312398-6.244253-55.9525467,2 [...]
+      <path class="cls-1" d="M12.4424433,216.3982816c-.7742201,1.4036946-1.4727012,3.0261906-2.068514,4.9651069-10.2920775,33.4496725,62.4846076,78.3847312,52.5830064,88.180298-8.9254108,8.8261086-20.5572255-11.3440069-34.9004936-3.0127259-1.5720033.9155994-3.1675699,2.0701971-4.8052136,3.749918-16.2215932,16.6188018-.2541461,64.4958967,45.7698699,90.0518908-10.7397786,36.0618236-21.4324308,76.351562-31.8995493,118.4859638,3.7970444-1.3296388,8.3413795-2.6626438,9.4724141-6.244253,1.6982 [...]
+    </g>
+  </g>
+  <g>
+    <polygon class="cls-1" points="404.1671788 144.7351535 413.43089 144.7351535 413.43089 82.5349876 436.7786739 82.5349876 436.7786739 73.4732468 380.8193949 73.4732468 380.8193949 82.5349876 404.1671788 82.5349876 404.1671788 144.7351535"/>
+    <polygon class="cls-1" points="464.8525663 112.9584696 501.321697 112.9584696 501.321697 144.7351535 510.6796611 144.7351535 510.6796611 73.4732468 501.321697 73.4732468 501.321697 103.8899964 464.8525663 103.8899964 464.8525663 73.4732468 455.5955874 73.4732468 455.5955874 144.7351535 464.8525663 144.7351535 464.8525663 112.9584696"/>
+    <polygon class="cls-1" points="581.40298 135.9629037 546.2331925 135.9629037 546.2331925 112.8574844 577.8213707 112.8574844 577.8213707 104.0852345 546.2331925 104.0852345 546.2331925 82.1512438 580.3392689 82.1512438 580.3392689 73.4732468 536.9762136 73.4732468 536.9762136 144.7351535 581.40298 144.7351535 581.40298 135.9629037"/>
+  </g>
+</svg>
\ No newline at end of file
diff --git a/themes/docsy/assets/scss/_styles_project.scss b/themes/docsy/assets/scss/_styles_project.scss
index e8b9b5a62024..745235426189 100644
--- a/themes/docsy/assets/scss/_styles_project.scss
+++ b/themes/docsy/assets/scss/_styles_project.scss
@@ -14,6 +14,7 @@ assets/scss/_styles_project.scss
 @import "sidebar-menu";
 @import "custom_team";
 @import "custom_contributors";
+@import "custom_certificate";
 @import "event-popup";
 
 //common
diff --git a/themes/docsy/layouts/partials/script-search.html b/themes/docsy/layouts/partials/script-search.html
index 9d774d0eac5c..26b6543e9a94 100644
--- a/themes/docsy/layouts/partials/script-search.html
+++ b/themes/docsy/layouts/partials/script-search.html
@@ -20,6 +20,7 @@
         }
       })
       $('.search-tips').hide();
+      $('.cert-cta').attr('href', $('.cert-cta').attr('href') + '?contributor=' + encodeURIComponent(contributor)).prop('hidden', false);
     } else {
       $('.search-tips').text('No data')
     }
diff --git a/themes/docsy/static/js/qrcode-generator-1.4.4.js b/themes/docsy/static/js/qrcode-generator-1.4.4.js
new file mode 100644
index 000000000000..76889b589ec8
--- /dev/null
+++ b/themes/docsy/static/js/qrcode-generator-1.4.4.js
@@ -0,0 +1,2297 @@
+//---------------------------------------------------------------------
+//
+// QR Code Generator for JavaScript
+//
+// Copyright (c) 2009 Kazuhiko Arase
+//
+// URL: http://www.d-project.com/
+//
+// Licensed under the MIT license:
+//  http://www.opensource.org/licenses/mit-license.php
+//
+// The word 'QR Code' is registered trademark of
+// DENSO WAVE INCORPORATED
+//  http://www.denso-wave.com/qrcode/faqpatent-e.html
+//
+//---------------------------------------------------------------------
+
+var qrcode = function() {
+
+  //---------------------------------------------------------------------
+  // qrcode
+  //---------------------------------------------------------------------
+
+  /**
+   * qrcode
+   * @param typeNumber 1 to 40
+   * @param errorCorrectionLevel 'L','M','Q','H'
+   */
+  var qrcode = function(typeNumber, errorCorrectionLevel) {
+
+    var PAD0 = 0xEC;
+    var PAD1 = 0x11;
+
+    var _typeNumber = typeNumber;
+    var _errorCorrectionLevel = QRErrorCorrectionLevel[errorCorrectionLevel];
+    var _modules = null;
+    var _moduleCount = 0;
+    var _dataCache = null;
+    var _dataList = [];
+
+    var _this = {};
+
+    var makeImpl = function(test, maskPattern) {
+
+      _moduleCount = _typeNumber * 4 + 17;
+      _modules = function(moduleCount) {
+        var modules = new Array(moduleCount);
+        for (var row = 0; row < moduleCount; row += 1) {
+          modules[row] = new Array(moduleCount);
+          for (var col = 0; col < moduleCount; col += 1) {
+            modules[row][col] = null;
+          }
+        }
+        return modules;
+      }(_moduleCount);
+
+      setupPositionProbePattern(0, 0);
+      setupPositionProbePattern(_moduleCount - 7, 0);
+      setupPositionProbePattern(0, _moduleCount - 7);
+      setupPositionAdjustPattern();
+      setupTimingPattern();
+      setupTypeInfo(test, maskPattern);
+
+      if (_typeNumber >= 7) {
+        setupTypeNumber(test);
+      }
+
+      if (_dataCache == null) {
+        _dataCache = createData(_typeNumber, _errorCorrectionLevel, _dataList);
+      }
+
+      mapData(_dataCache, maskPattern);
+    };
+
+    var setupPositionProbePattern = function(row, col) {
+
+      for (var r = -1; r <= 7; r += 1) {
+
+        if (row + r <= -1 || _moduleCount <= row + r) continue;
+
+        for (var c = -1; c <= 7; c += 1) {
+
+          if (col + c <= -1 || _moduleCount <= col + c) continue;
+
+          if ( (0 <= r && r <= 6 && (c == 0 || c == 6) )
+              || (0 <= c && c <= 6 && (r == 0 || r == 6) )
+              || (2 <= r && r <= 4 && 2 <= c && c <= 4) ) {
+            _modules[row + r][col + c] = true;
+          } else {
+            _modules[row + r][col + c] = false;
+          }
+        }
+      }
+    };
+
+    var getBestMaskPattern = function() {
+
+      var minLostPoint = 0;
+      var pattern = 0;
+
+      for (var i = 0; i < 8; i += 1) {
+
+        makeImpl(true, i);
+
+        var lostPoint = QRUtil.getLostPoint(_this);
+
+        if (i == 0 || minLostPoint > lostPoint) {
+          minLostPoint = lostPoint;
+          pattern = i;
+        }
+      }
+
+      return pattern;
+    };
+
+    var setupTimingPattern = function() {
+
+      for (var r = 8; r < _moduleCount - 8; r += 1) {
+        if (_modules[r][6] != null) {
+          continue;
+        }
+        _modules[r][6] = (r % 2 == 0);
+      }
+
+      for (var c = 8; c < _moduleCount - 8; c += 1) {
+        if (_modules[6][c] != null) {
+          continue;
+        }
+        _modules[6][c] = (c % 2 == 0);
+      }
+    };
+
+    var setupPositionAdjustPattern = function() {
+
+      var pos = QRUtil.getPatternPosition(_typeNumber);
+
+      for (var i = 0; i < pos.length; i += 1) {
+
+        for (var j = 0; j < pos.length; j += 1) {
+
+          var row = pos[i];
+          var col = pos[j];
+
+          if (_modules[row][col] != null) {
+            continue;
+          }
+
+          for (var r = -2; r <= 2; r += 1) {
+
+            for (var c = -2; c <= 2; c += 1) {
+
+              if (r == -2 || r == 2 || c == -2 || c == 2
+                  || (r == 0 && c == 0) ) {
+                _modules[row + r][col + c] = true;
+              } else {
+                _modules[row + r][col + c] = false;
+              }
+            }
+          }
+        }
+      }
+    };
+
+    var setupTypeNumber = function(test) {
+
+      var bits = QRUtil.getBCHTypeNumber(_typeNumber);
+
+      for (var i = 0; i < 18; i += 1) {
+        var mod = (!test && ( (bits >> i) & 1) == 1);
+        _modules[Math.floor(i / 3)][i % 3 + _moduleCount - 8 - 3] = mod;
+      }
+
+      for (var i = 0; i < 18; i += 1) {
+        var mod = (!test && ( (bits >> i) & 1) == 1);
+        _modules[i % 3 + _moduleCount - 8 - 3][Math.floor(i / 3)] = mod;
+      }
+    };
+
+    var setupTypeInfo = function(test, maskPattern) {
+
+      var data = (_errorCorrectionLevel << 3) | maskPattern;
+      var bits = QRUtil.getBCHTypeInfo(data);
+
+      // vertical
+      for (var i = 0; i < 15; i += 1) {
+
+        var mod = (!test && ( (bits >> i) & 1) == 1);
+
+        if (i < 6) {
+          _modules[i][8] = mod;
+        } else if (i < 8) {
+          _modules[i + 1][8] = mod;
+        } else {
+          _modules[_moduleCount - 15 + i][8] = mod;
+        }
+      }
+
+      // horizontal
+      for (var i = 0; i < 15; i += 1) {
+
+        var mod = (!test && ( (bits >> i) & 1) == 1);
+
+        if (i < 8) {
+          _modules[8][_moduleCount - i - 1] = mod;
+        } else if (i < 9) {
+          _modules[8][15 - i - 1 + 1] = mod;
+        } else {
+          _modules[8][15 - i - 1] = mod;
+        }
+      }
+
+      // fixed module
+      _modules[_moduleCount - 8][8] = (!test);
+    };
+
+    var mapData = function(data, maskPattern) {
+
+      var inc = -1;
+      var row = _moduleCount - 1;
+      var bitIndex = 7;
+      var byteIndex = 0;
+      var maskFunc = QRUtil.getMaskFunction(maskPattern);
+
+      for (var col = _moduleCount - 1; col > 0; col -= 2) {
+
+        if (col == 6) col -= 1;
+
+        while (true) {
+
+          for (var c = 0; c < 2; c += 1) {
+
+            if (_modules[row][col - c] == null) {
+
+              var dark = false;
+
+              if (byteIndex < data.length) {
+                dark = ( ( (data[byteIndex] >>> bitIndex) & 1) == 1);
+              }
+
+              var mask = maskFunc(row, col - c);
+
+              if (mask) {
+                dark = !dark;
+              }
+
+              _modules[row][col - c] = dark;
+              bitIndex -= 1;
+
+              if (bitIndex == -1) {
+                byteIndex += 1;
+                bitIndex = 7;
+              }
+            }
+          }
+
+          row += inc;
+
+          if (row < 0 || _moduleCount <= row) {
+            row -= inc;
+            inc = -inc;
+            break;
+          }
+        }
+      }
+    };
+
+    var createBytes = function(buffer, rsBlocks) {
+
+      var offset = 0;
+
+      var maxDcCount = 0;
+      var maxEcCount = 0;
+
+      var dcdata = new Array(rsBlocks.length);
+      var ecdata = new Array(rsBlocks.length);
+
+      for (var r = 0; r < rsBlocks.length; r += 1) {
+
+        var dcCount = rsBlocks[r].dataCount;
+        var ecCount = rsBlocks[r].totalCount - dcCount;
+
+        maxDcCount = Math.max(maxDcCount, dcCount);
+        maxEcCount = Math.max(maxEcCount, ecCount);
+
+        dcdata[r] = new Array(dcCount);
+
+        for (var i = 0; i < dcdata[r].length; i += 1) {
+          dcdata[r][i] = 0xff & buffer.getBuffer()[i + offset];
+        }
+        offset += dcCount;
+
+        var rsPoly = QRUtil.getErrorCorrectPolynomial(ecCount);
+        var rawPoly = qrPolynomial(dcdata[r], rsPoly.getLength() - 1);
+
+        var modPoly = rawPoly.mod(rsPoly);
+        ecdata[r] = new Array(rsPoly.getLength() - 1);
+        for (var i = 0; i < ecdata[r].length; i += 1) {
+          var modIndex = i + modPoly.getLength() - ecdata[r].length;
+          ecdata[r][i] = (modIndex >= 0)? modPoly.getAt(modIndex) : 0;
+        }
+      }
+
+      var totalCodeCount = 0;
+      for (var i = 0; i < rsBlocks.length; i += 1) {
+        totalCodeCount += rsBlocks[i].totalCount;
+      }
+
+      var data = new Array(totalCodeCount);
+      var index = 0;
+
+      for (var i = 0; i < maxDcCount; i += 1) {
+        for (var r = 0; r < rsBlocks.length; r += 1) {
+          if (i < dcdata[r].length) {
+            data[index] = dcdata[r][i];
+            index += 1;
+          }
+        }
+      }
+
+      for (var i = 0; i < maxEcCount; i += 1) {
+        for (var r = 0; r < rsBlocks.length; r += 1) {
+          if (i < ecdata[r].length) {
+            data[index] = ecdata[r][i];
+            index += 1;
+          }
+        }
+      }
+
+      return data;
+    };
+
+    var createData = function(typeNumber, errorCorrectionLevel, dataList) {
+
+      var rsBlocks = QRRSBlock.getRSBlocks(typeNumber, errorCorrectionLevel);
+
+      var buffer = qrBitBuffer();
+
+      for (var i = 0; i < dataList.length; i += 1) {
+        var data = dataList[i];
+        buffer.put(data.getMode(), 4);
+        buffer.put(data.getLength(), QRUtil.getLengthInBits(data.getMode(), typeNumber) );
+        data.write(buffer);
+      }
+
+      // calc num max data.
+      var totalDataCount = 0;
+      for (var i = 0; i < rsBlocks.length; i += 1) {
+        totalDataCount += rsBlocks[i].dataCount;
+      }
+
+      if (buffer.getLengthInBits() > totalDataCount * 8) {
+        throw 'code length overflow. ('
+          + buffer.getLengthInBits()
+          + '>'
+          + totalDataCount * 8
+          + ')';
+      }
+
+      // end code
+      if (buffer.getLengthInBits() + 4 <= totalDataCount * 8) {
+        buffer.put(0, 4);
+      }
+
+      // padding
+      while (buffer.getLengthInBits() % 8 != 0) {
+        buffer.putBit(false);
+      }
+
+      // padding
+      while (true) {
+
+        if (buffer.getLengthInBits() >= totalDataCount * 8) {
+          break;
+        }
+        buffer.put(PAD0, 8);
+
+        if (buffer.getLengthInBits() >= totalDataCount * 8) {
+          break;
+        }
+        buffer.put(PAD1, 8);
+      }
+
+      return createBytes(buffer, rsBlocks);
+    };
+
+    _this.addData = function(data, mode) {
+
+      mode = mode || 'Byte';
+
+      var newData = null;
+
+      switch(mode) {
+      case 'Numeric' :
+        newData = qrNumber(data);
+        break;
+      case 'Alphanumeric' :
+        newData = qrAlphaNum(data);
+        break;
+      case 'Byte' :
+        newData = qr8BitByte(data);
+        break;
+      case 'Kanji' :
+        newData = qrKanji(data);
+        break;
+      default :
+        throw 'mode:' + mode;
+      }
+
+      _dataList.push(newData);
+      _dataCache = null;
+    };
+
+    _this.isDark = function(row, col) {
+      if (row < 0 || _moduleCount <= row || col < 0 || _moduleCount <= col) {
+        throw row + ',' + col;
+      }
+      return _modules[row][col];
+    };
+
+    _this.getModuleCount = function() {
+      return _moduleCount;
+    };
+
+    _this.make = function() {
+      if (_typeNumber < 1) {
+        var typeNumber = 1;
+
+        for (; typeNumber < 40; typeNumber++) {
+          var rsBlocks = QRRSBlock.getRSBlocks(typeNumber, _errorCorrectionLevel);
+          var buffer = qrBitBuffer();
+
+          for (var i = 0; i < _dataList.length; i++) {
+            var data = _dataList[i];
+            buffer.put(data.getMode(), 4);
+            buffer.put(data.getLength(), QRUtil.getLengthInBits(data.getMode(), typeNumber) );
+            data.write(buffer);
+          }
+
+          var totalDataCount = 0;
+          for (var i = 0; i < rsBlocks.length; i++) {
+            totalDataCount += rsBlocks[i].dataCount;
+          }
+
+          if (buffer.getLengthInBits() <= totalDataCount * 8) {
+            break;
+          }
+        }
+
+        _typeNumber = typeNumber;
+      }
+
+      makeImpl(false, getBestMaskPattern() );
+    };
+
+    _this.createTableTag = function(cellSize, margin) {
+
+      cellSize = cellSize || 2;
+      margin = (typeof margin == 'undefined')? cellSize * 4 : margin;
+
+      var qrHtml = '';
+
+      qrHtml += '<table style="';
+      qrHtml += ' border-width: 0px; border-style: none;';
+      qrHtml += ' border-collapse: collapse;';
+      qrHtml += ' padding: 0px; margin: ' + margin + 'px;';
+      qrHtml += '">';
+      qrHtml += '<tbody>';
+
+      for (var r = 0; r < _this.getModuleCount(); r += 1) {
+
+        qrHtml += '<tr>';
+
+        for (var c = 0; c < _this.getModuleCount(); c += 1) {
+          qrHtml += '<td style="';
+          qrHtml += ' border-width: 0px; border-style: none;';
+          qrHtml += ' border-collapse: collapse;';
+          qrHtml += ' padding: 0px; margin: 0px;';
+          qrHtml += ' width: ' + cellSize + 'px;';
+          qrHtml += ' height: ' + cellSize + 'px;';
+          qrHtml += ' background-color: ';
+          qrHtml += _this.isDark(r, c)? '#000000' : '#ffffff';
+          qrHtml += ';';
+          qrHtml += '"/>';
+        }
+
+        qrHtml += '</tr>';
+      }
+
+      qrHtml += '</tbody>';
+      qrHtml += '</table>';
+
+      return qrHtml;
+    };
+
+    _this.createSvgTag = function(cellSize, margin, alt, title) {
+
+      var opts = {};
+      if (typeof arguments[0] == 'object') {
+        // Called by options.
+        opts = arguments[0];
+        // overwrite cellSize and margin.
+        cellSize = opts.cellSize;
+        margin = opts.margin;
+        alt = opts.alt;
+        title = opts.title;
+      }
+
+      cellSize = cellSize || 2;
+      margin = (typeof margin == 'undefined')? cellSize * 4 : margin;
+
+      // Compose alt property surrogate
+      alt = (typeof alt === 'string') ? {text: alt} : alt || {};
+      alt.text = alt.text || null;
+      alt.id = (alt.text) ? alt.id || 'qrcode-description' : null;
+
+      // Compose title property surrogate
+      title = (typeof title === 'string') ? {text: title} : title || {};
+      title.text = title.text || null;
+      title.id = (title.text) ? title.id || 'qrcode-title' : null;
+
+      var size = _this.getModuleCount() * cellSize + margin * 2;
+      var c, mc, r, mr, qrSvg='', rect;
+
+      rect = 'l' + cellSize + ',0 0,' + cellSize +
+        ' -' + cellSize + ',0 0,-' + cellSize + 'z ';
+
+      qrSvg += '<svg version="1.1" xmlns="http://www.w3.org/2000/svg"';
+      qrSvg += !opts.scalable ? ' width="' + size + 'px" height="' + size + 'px"' : '';
+      qrSvg += ' viewBox="0 0 ' + size + ' ' + size + '" ';
+      qrSvg += ' preserveAspectRatio="xMinYMin meet"';
+      qrSvg += (title.text || alt.text) ? ' role="img" aria-labelledby="' +
+          escapeXml([title.id, alt.id].join(' ').trim() ) + '"' : '';
+      qrSvg += '>';
+      qrSvg += (title.text) ? '<title id="' + escapeXml(title.id) + '">' +
+          escapeXml(title.text) + '</title>' : '';
+      qrSvg += (alt.text) ? '<description id="' + escapeXml(alt.id) + '">' +
+          escapeXml(alt.text) + '</description>' : '';
+      qrSvg += '<rect width="100%" height="100%" fill="white" cx="0" cy="0"/>';
+      qrSvg += '<path d="';
+
+      for (r = 0; r < _this.getModuleCount(); r += 1) {
+        mr = r * cellSize + margin;
+        for (c = 0; c < _this.getModuleCount(); c += 1) {
+          if (_this.isDark(r, c) ) {
+            mc = c*cellSize+margin;
+            qrSvg += 'M' + mc + ',' + mr + rect;
+          }
+        }
+      }
+
+      qrSvg += '" stroke="transparent" fill="black"/>';
+      qrSvg += '</svg>';
+
+      return qrSvg;
+    };
+
+    _this.createDataURL = function(cellSize, margin) {
+
+      cellSize = cellSize || 2;
+      margin = (typeof margin == 'undefined')? cellSize * 4 : margin;
+
+      var size = _this.getModuleCount() * cellSize + margin * 2;
+      var min = margin;
+      var max = size - margin;
+
+      return createDataURL(size, size, function(x, y) {
+        if (min <= x && x < max && min <= y && y < max) {
+          var c = Math.floor( (x - min) / cellSize);
+          var r = Math.floor( (y - min) / cellSize);
+          return _this.isDark(r, c)? 0 : 1;
+        } else {
+          return 1;
+        }
+      } );
+    };
+
+    _this.createImgTag = function(cellSize, margin, alt) {
+
+      cellSize = cellSize || 2;
+      margin = (typeof margin == 'undefined')? cellSize * 4 : margin;
+
+      var size = _this.getModuleCount() * cellSize + margin * 2;
+
+      var img = '';
+      img += '<img';
+      img += '\u0020src="';
+      img += _this.createDataURL(cellSize, margin);
+      img += '"';
+      img += '\u0020width="';
+      img += size;
+      img += '"';
+      img += '\u0020height="';
+      img += size;
+      img += '"';
+      if (alt) {
+        img += '\u0020alt="';
+        img += escapeXml(alt);
+        img += '"';
+      }
+      img += '/>';
+
+      return img;
+    };
+
+    var escapeXml = function(s) {
+      var escaped = '';
+      for (var i = 0; i < s.length; i += 1) {
+        var c = s.charAt(i);
+        switch(c) {
+        case '<': escaped += '&lt;'; break;
+        case '>': escaped += '&gt;'; break;
+        case '&': escaped += '&amp;'; break;
+        case '"': escaped += '&quot;'; break;
+        default : escaped += c; break;
+        }
+      }
+      return escaped;
+    };
+
+    var _createHalfASCII = function(margin) {
+      var cellSize = 1;
+      margin = (typeof margin == 'undefined')? cellSize * 2 : margin;
+
+      var size = _this.getModuleCount() * cellSize + margin * 2;
+      var min = margin;
+      var max = size - margin;
+
+      var y, x, r1, r2, p;
+
+      var blocks = {
+        '██': '█',
+        '█ ': '▀',
+        ' █': '▄',
+        '  ': ' '
+      };
+
+      var blocksLastLineNoMargin = {
+        '██': '▀',
+        '█ ': '▀',
+        ' █': ' ',
+        '  ': ' '
+      };
+
+      var ascii = '';
+      for (y = 0; y < size; y += 2) {
+        r1 = Math.floor((y - min) / cellSize);
+        r2 = Math.floor((y + 1 - min) / cellSize);
+        for (x = 0; x < size; x += 1) {
+          p = '█';
+
+          if (min <= x && x < max && min <= y && y < max && _this.isDark(r1, Math.floor((x - min) / cellSize))) {
+            p = ' ';
+          }
+
+          if (min <= x && x < max && min <= y+1 && y+1 < max && _this.isDark(r2, Math.floor((x - min) / cellSize))) {
+            p += ' ';
+          }
+          else {
+            p += '█';
+          }
+
+          // Output 2 characters per pixel, to create full square. 1 character per pixels gives only half width of square.
+          ascii += (margin < 1 && y+1 >= max) ? blocksLastLineNoMargin[p] : blocks[p];
+        }
+
+        ascii += '\n';
+      }
+
+      if (size % 2 && margin > 0) {
+        return ascii.substring(0, ascii.length - size - 1) + Array(size+1).join('▀');
+      }
+
+      return ascii.substring(0, ascii.length-1);
+    };
+
+    _this.createASCII = function(cellSize, margin) {
+      cellSize = cellSize || 1;
+
+      if (cellSize < 2) {
+        return _createHalfASCII(margin);
+      }
+
+      cellSize -= 1;
+      margin = (typeof margin == 'undefined')? cellSize * 2 : margin;
+
+      var size = _this.getModuleCount() * cellSize + margin * 2;
+      var min = margin;
+      var max = size - margin;
+
+      var y, x, r, p;
+
+      var white = Array(cellSize+1).join('██');
+      var black = Array(cellSize+1).join('  ');
+
+      var ascii = '';
+      var line = '';
+      for (y = 0; y < size; y += 1) {
+        r = Math.floor( (y - min) / cellSize);
+        line = '';
+        for (x = 0; x < size; x += 1) {
+          p = 1;
+
+          if (min <= x && x < max && min <= y && y < max && _this.isDark(r, Math.floor((x - min) / cellSize))) {
+            p = 0;
+          }
+
+          // Output 2 characters per pixel, to create full square. 1 character per pixels gives only half width of square.
+          line += p ? white : black;
+        }
+
+        for (r = 0; r < cellSize; r += 1) {
+          ascii += line + '\n';
+        }
+      }
+
+      return ascii.substring(0, ascii.length-1);
+    };
+
+    _this.renderTo2dContext = function(context, cellSize) {
+      cellSize = cellSize || 2;
+      var length = _this.getModuleCount();
+      for (var row = 0; row < length; row++) {
+        for (var col = 0; col < length; col++) {
+          context.fillStyle = _this.isDark(row, col) ? 'black' : 'white';
+          context.fillRect(row * cellSize, col * cellSize, cellSize, cellSize);
+        }
+      }
+    }
+
+    return _this;
+  };
+
+  //---------------------------------------------------------------------
+  // qrcode.stringToBytes
+  //---------------------------------------------------------------------
+
+  qrcode.stringToBytesFuncs = {
+    'default' : function(s) {
+      var bytes = [];
+      for (var i = 0; i < s.length; i += 1) {
+        var c = s.charCodeAt(i);
+        bytes.push(c & 0xff);
+      }
+      return bytes;
+    }
+  };
+
+  qrcode.stringToBytes = qrcode.stringToBytesFuncs['default'];
+
+  //---------------------------------------------------------------------
+  // qrcode.createStringToBytes
+  //---------------------------------------------------------------------
+
+  /**
+   * @param unicodeData base64 string of byte array.
+   * [16bit Unicode],[16bit Bytes], ...
+   * @param numChars
+   */
+  qrcode.createStringToBytes = function(unicodeData, numChars) {
+
+    // create conversion map.
+
+    var unicodeMap = function() {
+
+      var bin = base64DecodeInputStream(unicodeData);
+      var read = function() {
+        var b = bin.read();
+        if (b == -1) throw 'eof';
+        return b;
+      };
+
+      var count = 0;
+      var unicodeMap = {};
+      while (true) {
+        var b0 = bin.read();
+        if (b0 == -1) break;
+        var b1 = read();
+        var b2 = read();
+        var b3 = read();
+        var k = String.fromCharCode( (b0 << 8) | b1);
+        var v = (b2 << 8) | b3;
+        unicodeMap[k] = v;
+        count += 1;
+      }
+      if (count != numChars) {
+        throw count + ' != ' + numChars;
+      }
+
+      return unicodeMap;
+    }();
+
+    var unknownChar = '?'.charCodeAt(0);
+
+    return function(s) {
+      var bytes = [];
+      for (var i = 0; i < s.length; i += 1) {
+        var c = s.charCodeAt(i);
+        if (c < 128) {
+          bytes.push(c);
+        } else {
+          var b = unicodeMap[s.charAt(i)];
+          if (typeof b == 'number') {
+            if ( (b & 0xff) == b) {
+              // 1byte
+              bytes.push(b);
+            } else {
+              // 2bytes
+              bytes.push(b >>> 8);
+              bytes.push(b & 0xff);
+            }
+          } else {
+            bytes.push(unknownChar);
+          }
+        }
+      }
+      return bytes;
+    };
+  };
+
+  //---------------------------------------------------------------------
+  // QRMode
+  //---------------------------------------------------------------------
+
+  var QRMode = {
+    MODE_NUMBER :    1 << 0,
+    MODE_ALPHA_NUM : 1 << 1,
+    MODE_8BIT_BYTE : 1 << 2,
+    MODE_KANJI :     1 << 3
+  };
+
+  //---------------------------------------------------------------------
+  // QRErrorCorrectionLevel
+  //---------------------------------------------------------------------
+
+  var QRErrorCorrectionLevel = {
+    L : 1,
+    M : 0,
+    Q : 3,
+    H : 2
+  };
+
+  //---------------------------------------------------------------------
+  // QRMaskPattern
+  //---------------------------------------------------------------------
+
+  var QRMaskPattern = {
+    PATTERN000 : 0,
+    PATTERN001 : 1,
+    PATTERN010 : 2,
+    PATTERN011 : 3,
+    PATTERN100 : 4,
+    PATTERN101 : 5,
+    PATTERN110 : 6,
+    PATTERN111 : 7
+  };
+
+  //---------------------------------------------------------------------
+  // QRUtil
+  //---------------------------------------------------------------------
+
+  var QRUtil = function() {
+
+    var PATTERN_POSITION_TABLE = [
+      [],
+      [6, 18],
+      [6, 22],
+      [6, 26],
+      [6, 30],
+      [6, 34],
+      [6, 22, 38],
+      [6, 24, 42],
+      [6, 26, 46],
+      [6, 28, 50],
+      [6, 30, 54],
+      [6, 32, 58],
+      [6, 34, 62],
+      [6, 26, 46, 66],
+      [6, 26, 48, 70],
+      [6, 26, 50, 74],
+      [6, 30, 54, 78],
+      [6, 30, 56, 82],
+      [6, 30, 58, 86],
+      [6, 34, 62, 90],
+      [6, 28, 50, 72, 94],
+      [6, 26, 50, 74, 98],
+      [6, 30, 54, 78, 102],
+      [6, 28, 54, 80, 106],
+      [6, 32, 58, 84, 110],
+      [6, 30, 58, 86, 114],
+      [6, 34, 62, 90, 118],
+      [6, 26, 50, 74, 98, 122],
+      [6, 30, 54, 78, 102, 126],
+      [6, 26, 52, 78, 104, 130],
+      [6, 30, 56, 82, 108, 134],
+      [6, 34, 60, 86, 112, 138],
+      [6, 30, 58, 86, 114, 142],
+      [6, 34, 62, 90, 118, 146],
+      [6, 30, 54, 78, 102, 126, 150],
+      [6, 24, 50, 76, 102, 128, 154],
+      [6, 28, 54, 80, 106, 132, 158],
+      [6, 32, 58, 84, 110, 136, 162],
+      [6, 26, 54, 82, 110, 138, 166],
+      [6, 30, 58, 86, 114, 142, 170]
+    ];
+    var G15 = (1 << 10) | (1 << 8) | (1 << 5) | (1 << 4) | (1 << 2) | (1 << 1) | (1 << 0);
+    var G18 = (1 << 12) | (1 << 11) | (1 << 10) | (1 << 9) | (1 << 8) | (1 << 5) | (1 << 2) | (1 << 0);
+    var G15_MASK = (1 << 14) | (1 << 12) | (1 << 10) | (1 << 4) | (1 << 1);
+
+    var _this = {};
+
+    var getBCHDigit = function(data) {
+      var digit = 0;
+      while (data != 0) {
+        digit += 1;
+        data >>>= 1;
+      }
+      return digit;
+    };
+
+    _this.getBCHTypeInfo = function(data) {
+      var d = data << 10;
+      while (getBCHDigit(d) - getBCHDigit(G15) >= 0) {
+        d ^= (G15 << (getBCHDigit(d) - getBCHDigit(G15) ) );
+      }
+      return ( (data << 10) | d) ^ G15_MASK;
+    };
+
+    _this.getBCHTypeNumber = function(data) {
+      var d = data << 12;
+      while (getBCHDigit(d) - getBCHDigit(G18) >= 0) {
+        d ^= (G18 << (getBCHDigit(d) - getBCHDigit(G18) ) );
+      }
+      return (data << 12) | d;
+    };
+
+    _this.getPatternPosition = function(typeNumber) {
+      return PATTERN_POSITION_TABLE[typeNumber - 1];
+    };
+
+    _this.getMaskFunction = function(maskPattern) {
+
+      switch (maskPattern) {
+
+      case QRMaskPattern.PATTERN000 :
+        return function(i, j) { return (i + j) % 2 == 0; };
+      case QRMaskPattern.PATTERN001 :
+        return function(i, j) { return i % 2 == 0; };
+      case QRMaskPattern.PATTERN010 :
+        return function(i, j) { return j % 3 == 0; };
+      case QRMaskPattern.PATTERN011 :
+        return function(i, j) { return (i + j) % 3 == 0; };
+      case QRMaskPattern.PATTERN100 :
+        return function(i, j) { return (Math.floor(i / 2) + Math.floor(j / 3) ) % 2 == 0; };
+      case QRMaskPattern.PATTERN101 :
+        return function(i, j) { return (i * j) % 2 + (i * j) % 3 == 0; };
+      case QRMaskPattern.PATTERN110 :
+        return function(i, j) { return ( (i * j) % 2 + (i * j) % 3) % 2 == 0; };
+      case QRMaskPattern.PATTERN111 :
+        return function(i, j) { return ( (i * j) % 3 + (i + j) % 2) % 2 == 0; };
+
+      default :
+        throw 'bad maskPattern:' + maskPattern;
+      }
+    };
+
+    _this.getErrorCorrectPolynomial = function(errorCorrectLength) {
+      var a = qrPolynomial([1], 0);
+      for (var i = 0; i < errorCorrectLength; i += 1) {
+        a = a.multiply(qrPolynomial([1, QRMath.gexp(i)], 0) );
+      }
+      return a;
+    };
+
+    _this.getLengthInBits = function(mode, type) {
+
+      if (1 <= type && type < 10) {
+
+        // 1 - 9
+
+        switch(mode) {
+        case QRMode.MODE_NUMBER    : return 10;
+        case QRMode.MODE_ALPHA_NUM : return 9;
+        case QRMode.MODE_8BIT_BYTE : return 8;
+        case QRMode.MODE_KANJI     : return 8;
+        default :
+          throw 'mode:' + mode;
+        }
+
+      } else if (type < 27) {
+
+        // 10 - 26
+
+        switch(mode) {
+        case QRMode.MODE_NUMBER    : return 12;
+        case QRMode.MODE_ALPHA_NUM : return 11;
+        case QRMode.MODE_8BIT_BYTE : return 16;
+        case QRMode.MODE_KANJI     : return 10;
+        default :
+          throw 'mode:' + mode;
+        }
+
+      } else if (type < 41) {
+
+        // 27 - 40
+
+        switch(mode) {
+        case QRMode.MODE_NUMBER    : return 14;
+        case QRMode.MODE_ALPHA_NUM : return 13;
+        case QRMode.MODE_8BIT_BYTE : return 16;
+        case QRMode.MODE_KANJI     : return 12;
+        default :
+          throw 'mode:' + mode;
+        }
+
+      } else {
+        throw 'type:' + type;
+      }
+    };
+
+    _this.getLostPoint = function(qrcode) {
+
+      var moduleCount = qrcode.getModuleCount();
+
+      var lostPoint = 0;
+
+      // LEVEL1
+
+      for (var row = 0; row < moduleCount; row += 1) {
+        for (var col = 0; col < moduleCount; col += 1) {
+
+          var sameCount = 0;
+          var dark = qrcode.isDark(row, col);
+
+          for (var r = -1; r <= 1; r += 1) {
+
+            if (row + r < 0 || moduleCount <= row + r) {
+              continue;
+            }
+
+            for (var c = -1; c <= 1; c += 1) {
+
+              if (col + c < 0 || moduleCount <= col + c) {
+                continue;
+              }
+
+              if (r == 0 && c == 0) {
+                continue;
+              }
+
+              if (dark == qrcode.isDark(row + r, col + c) ) {
+                sameCount += 1;
+              }
+            }
+          }
+
+          if (sameCount > 5) {
+            lostPoint += (3 + sameCount - 5);
+          }
+        }
+      };
+
+      // LEVEL2
+
+      for (var row = 0; row < moduleCount - 1; row += 1) {
+        for (var col = 0; col < moduleCount - 1; col += 1) {
+          var count = 0;
+          if (qrcode.isDark(row, col) ) count += 1;
+          if (qrcode.isDark(row + 1, col) ) count += 1;
+          if (qrcode.isDark(row, col + 1) ) count += 1;
+          if (qrcode.isDark(row + 1, col + 1) ) count += 1;
+          if (count == 0 || count == 4) {
+            lostPoint += 3;
+          }
+        }
+      }
+
+      // LEVEL3
+
+      for (var row = 0; row < moduleCount; row += 1) {
+        for (var col = 0; col < moduleCount - 6; col += 1) {
+          if (qrcode.isDark(row, col)
+              && !qrcode.isDark(row, col + 1)
+              &&  qrcode.isDark(row, col + 2)
+              &&  qrcode.isDark(row, col + 3)
+              &&  qrcode.isDark(row, col + 4)
+              && !qrcode.isDark(row, col + 5)
+              &&  qrcode.isDark(row, col + 6) ) {
+            lostPoint += 40;
+          }
+        }
+      }
+
+      for (var col = 0; col < moduleCount; col += 1) {
+        for (var row = 0; row < moduleCount - 6; row += 1) {
+          if (qrcode.isDark(row, col)
+              && !qrcode.isDark(row + 1, col)
+              &&  qrcode.isDark(row + 2, col)
+              &&  qrcode.isDark(row + 3, col)
+              &&  qrcode.isDark(row + 4, col)
+              && !qrcode.isDark(row + 5, col)
+              &&  qrcode.isDark(row + 6, col) ) {
+            lostPoint += 40;
+          }
+        }
+      }
+
+      // LEVEL4
+
+      var darkCount = 0;
+
+      for (var col = 0; col < moduleCount; col += 1) {
+        for (var row = 0; row < moduleCount; row += 1) {
+          if (qrcode.isDark(row, col) ) {
+            darkCount += 1;
+          }
+        }
+      }
+
+      var ratio = Math.abs(100 * darkCount / moduleCount / moduleCount - 50) / 5;
+      lostPoint += ratio * 10;
+
+      return lostPoint;
+    };
+
+    return _this;
+  }();
+
+  //---------------------------------------------------------------------
+  // QRMath
+  //---------------------------------------------------------------------
+
+  var QRMath = function() {
+
+    var EXP_TABLE = new Array(256);
+    var LOG_TABLE = new Array(256);
+
+    // initialize tables
+    for (var i = 0; i < 8; i += 1) {
+      EXP_TABLE[i] = 1 << i;
+    }
+    for (var i = 8; i < 256; i += 1) {
+      EXP_TABLE[i] = EXP_TABLE[i - 4]
+        ^ EXP_TABLE[i - 5]
+        ^ EXP_TABLE[i - 6]
+        ^ EXP_TABLE[i - 8];
+    }
+    for (var i = 0; i < 255; i += 1) {
+      LOG_TABLE[EXP_TABLE[i] ] = i;
+    }
+
+    var _this = {};
+
+    _this.glog = function(n) {
+
+      if (n < 1) {
+        throw 'glog(' + n + ')';
+      }
+
+      return LOG_TABLE[n];
+    };
+
+    _this.gexp = function(n) {
+
+      while (n < 0) {
+        n += 255;
+      }
+
+      while (n >= 256) {
+        n -= 255;
+      }
+
+      return EXP_TABLE[n];
+    };
+
+    return _this;
+  }();
+
+  //---------------------------------------------------------------------
+  // qrPolynomial
+  //---------------------------------------------------------------------
+
+  function qrPolynomial(num, shift) {
+
+    if (typeof num.length == 'undefined') {
+      throw num.length + '/' + shift;
+    }
+
+    var _num = function() {
+      var offset = 0;
+      while (offset < num.length && num[offset] == 0) {
+        offset += 1;
+      }
+      var _num = new Array(num.length - offset + shift);
+      for (var i = 0; i < num.length - offset; i += 1) {
+        _num[i] = num[i + offset];
+      }
+      return _num;
+    }();
+
+    var _this = {};
+
+    _this.getAt = function(index) {
+      return _num[index];
+    };
+
+    _this.getLength = function() {
+      return _num.length;
+    };
+
+    _this.multiply = function(e) {
+
+      var num = new Array(_this.getLength() + e.getLength() - 1);
+
+      for (var i = 0; i < _this.getLength(); i += 1) {
+        for (var j = 0; j < e.getLength(); j += 1) {
+          num[i + j] ^= QRMath.gexp(QRMath.glog(_this.getAt(i) ) + QRMath.glog(e.getAt(j) ) );
+        }
+      }
+
+      return qrPolynomial(num, 0);
+    };
+
+    _this.mod = function(e) {
+
+      if (_this.getLength() - e.getLength() < 0) {
+        return _this;
+      }
+
+      var ratio = QRMath.glog(_this.getAt(0) ) - QRMath.glog(e.getAt(0) );
+
+      var num = new Array(_this.getLength() );
+      for (var i = 0; i < _this.getLength(); i += 1) {
+        num[i] = _this.getAt(i);
+      }
+
+      for (var i = 0; i < e.getLength(); i += 1) {
+        num[i] ^= QRMath.gexp(QRMath.glog(e.getAt(i) ) + ratio);
+      }
+
+      // recursive call
+      return qrPolynomial(num, 0).mod(e);
+    };
+
+    return _this;
+  };
+
+  //---------------------------------------------------------------------
+  // QRRSBlock
+  //---------------------------------------------------------------------
+
+  var QRRSBlock = function() {
+
+    var RS_BLOCK_TABLE = [
+
+      // L
+      // M
+      // Q
+      // H
+
+      // 1
+      [1, 26, 19],
+      [1, 26, 16],
+      [1, 26, 13],
+      [1, 26, 9],
+
+      // 2
+      [1, 44, 34],
+      [1, 44, 28],
+      [1, 44, 22],
+      [1, 44, 16],
+
+      // 3
+      [1, 70, 55],
+      [1, 70, 44],
+      [2, 35, 17],
+      [2, 35, 13],
+
+      // 4
+      [1, 100, 80],
+      [2, 50, 32],
+      [2, 50, 24],
+      [4, 25, 9],
+
+      // 5
+      [1, 134, 108],
+      [2, 67, 43],
+      [2, 33, 15, 2, 34, 16],
+      [2, 33, 11, 2, 34, 12],
+
+      // 6
+      [2, 86, 68],
+      [4, 43, 27],
+      [4, 43, 19],
+      [4, 43, 15],
+
+      // 7
+      [2, 98, 78],
+      [4, 49, 31],
+      [2, 32, 14, 4, 33, 15],
+      [4, 39, 13, 1, 40, 14],
+
+      // 8
+      [2, 121, 97],
+      [2, 60, 38, 2, 61, 39],
+      [4, 40, 18, 2, 41, 19],
+      [4, 40, 14, 2, 41, 15],
+
+      // 9
+      [2, 146, 116],
+      [3, 58, 36, 2, 59, 37],
+      [4, 36, 16, 4, 37, 17],
+      [4, 36, 12, 4, 37, 13],
+
+      // 10
+      [2, 86, 68, 2, 87, 69],
+      [4, 69, 43, 1, 70, 44],
+      [6, 43, 19, 2, 44, 20],
+      [6, 43, 15, 2, 44, 16],
+
+      // 11
+      [4, 101, 81],
+      [1, 80, 50, 4, 81, 51],
+      [4, 50, 22, 4, 51, 23],
+      [3, 36, 12, 8, 37, 13],
+
+      // 12
+      [2, 116, 92, 2, 117, 93],
+      [6, 58, 36, 2, 59, 37],
+      [4, 46, 20, 6, 47, 21],
+      [7, 42, 14, 4, 43, 15],
+
+      // 13
+      [4, 133, 107],
+      [8, 59, 37, 1, 60, 38],
+      [8, 44, 20, 4, 45, 21],
+      [12, 33, 11, 4, 34, 12],
+
+      // 14
+      [3, 145, 115, 1, 146, 116],
+      [4, 64, 40, 5, 65, 41],
+      [11, 36, 16, 5, 37, 17],
+      [11, 36, 12, 5, 37, 13],
+
+      // 15
+      [5, 109, 87, 1, 110, 88],
+      [5, 65, 41, 5, 66, 42],
+      [5, 54, 24, 7, 55, 25],
+      [11, 36, 12, 7, 37, 13],
+
+      // 16
+      [5, 122, 98, 1, 123, 99],
+      [7, 73, 45, 3, 74, 46],
+      [15, 43, 19, 2, 44, 20],
+      [3, 45, 15, 13, 46, 16],
+
+      // 17
+      [1, 135, 107, 5, 136, 108],
+      [10, 74, 46, 1, 75, 47],
+      [1, 50, 22, 15, 51, 23],
+      [2, 42, 14, 17, 43, 15],
+
+      // 18
+      [5, 150, 120, 1, 151, 121],
+      [9, 69, 43, 4, 70, 44],
+      [17, 50, 22, 1, 51, 23],
+      [2, 42, 14, 19, 43, 15],
+
+      // 19
+      [3, 141, 113, 4, 142, 114],
+      [3, 70, 44, 11, 71, 45],
+      [17, 47, 21, 4, 48, 22],
+      [9, 39, 13, 16, 40, 14],
+
+      // 20
+      [3, 135, 107, 5, 136, 108],
+      [3, 67, 41, 13, 68, 42],
+      [15, 54, 24, 5, 55, 25],
+      [15, 43, 15, 10, 44, 16],
+
+      // 21
+      [4, 144, 116, 4, 145, 117],
+      [17, 68, 42],
+      [17, 50, 22, 6, 51, 23],
+      [19, 46, 16, 6, 47, 17],
+
+      // 22
+      [2, 139, 111, 7, 140, 112],
+      [17, 74, 46],
+      [7, 54, 24, 16, 55, 25],
+      [34, 37, 13],
+
+      // 23
+      [4, 151, 121, 5, 152, 122],
+      [4, 75, 47, 14, 76, 48],
+      [11, 54, 24, 14, 55, 25],
+      [16, 45, 15, 14, 46, 16],
+
+      // 24
+      [6, 147, 117, 4, 148, 118],
+      [6, 73, 45, 14, 74, 46],
+      [11, 54, 24, 16, 55, 25],
+      [30, 46, 16, 2, 47, 17],
+
+      // 25
+      [8, 132, 106, 4, 133, 107],
+      [8, 75, 47, 13, 76, 48],
+      [7, 54, 24, 22, 55, 25],
+      [22, 45, 15, 13, 46, 16],
+
+      // 26
+      [10, 142, 114, 2, 143, 115],
+      [19, 74, 46, 4, 75, 47],
+      [28, 50, 22, 6, 51, 23],
+      [33, 46, 16, 4, 47, 17],
+
+      // 27
+      [8, 152, 122, 4, 153, 123],
+      [22, 73, 45, 3, 74, 46],
+      [8, 53, 23, 26, 54, 24],
+      [12, 45, 15, 28, 46, 16],
+
+      // 28
+      [3, 147, 117, 10, 148, 118],
+      [3, 73, 45, 23, 74, 46],
+      [4, 54, 24, 31, 55, 25],
+      [11, 45, 15, 31, 46, 16],
+
+      // 29
+      [7, 146, 116, 7, 147, 117],
+      [21, 73, 45, 7, 74, 46],
+      [1, 53, 23, 37, 54, 24],
+      [19, 45, 15, 26, 46, 16],
+
+      // 30
+      [5, 145, 115, 10, 146, 116],
+      [19, 75, 47, 10, 76, 48],
+      [15, 54, 24, 25, 55, 25],
+      [23, 45, 15, 25, 46, 16],
+
+      // 31
+      [13, 145, 115, 3, 146, 116],
+      [2, 74, 46, 29, 75, 47],
+      [42, 54, 24, 1, 55, 25],
+      [23, 45, 15, 28, 46, 16],
+
+      // 32
+      [17, 145, 115],
+      [10, 74, 46, 23, 75, 47],
+      [10, 54, 24, 35, 55, 25],
+      [19, 45, 15, 35, 46, 16],
+
+      // 33
+      [17, 145, 115, 1, 146, 116],
+      [14, 74, 46, 21, 75, 47],
+      [29, 54, 24, 19, 55, 25],
+      [11, 45, 15, 46, 46, 16],
+
+      // 34
+      [13, 145, 115, 6, 146, 116],
+      [14, 74, 46, 23, 75, 47],
+      [44, 54, 24, 7, 55, 25],
+      [59, 46, 16, 1, 47, 17],
+
+      // 35
+      [12, 151, 121, 7, 152, 122],
+      [12, 75, 47, 26, 76, 48],
+      [39, 54, 24, 14, 55, 25],
+      [22, 45, 15, 41, 46, 16],
+
+      // 36
+      [6, 151, 121, 14, 152, 122],
+      [6, 75, 47, 34, 76, 48],
+      [46, 54, 24, 10, 55, 25],
+      [2, 45, 15, 64, 46, 16],
+
+      // 37
+      [17, 152, 122, 4, 153, 123],
+      [29, 74, 46, 14, 75, 47],
+      [49, 54, 24, 10, 55, 25],
+      [24, 45, 15, 46, 46, 16],
+
+      // 38
+      [4, 152, 122, 18, 153, 123],
+      [13, 74, 46, 32, 75, 47],
+      [48, 54, 24, 14, 55, 25],
+      [42, 45, 15, 32, 46, 16],
+
+      // 39
+      [20, 147, 117, 4, 148, 118],
+      [40, 75, 47, 7, 76, 48],
+      [43, 54, 24, 22, 55, 25],
+      [10, 45, 15, 67, 46, 16],
+
+      // 40
+      [19, 148, 118, 6, 149, 119],
+      [18, 75, 47, 31, 76, 48],
+      [34, 54, 24, 34, 55, 25],
+      [20, 45, 15, 61, 46, 16]
+    ];
+
+    var qrRSBlock = function(totalCount, dataCount) {
+      var _this = {};
+      _this.totalCount = totalCount;
+      _this.dataCount = dataCount;
+      return _this;
+    };
+
+    var _this = {};
+
+    var getRsBlockTable = function(typeNumber, errorCorrectionLevel) {
+
+      switch(errorCorrectionLevel) {
+      case QRErrorCorrectionLevel.L :
+        return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 0];
+      case QRErrorCorrectionLevel.M :
+        return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 1];
+      case QRErrorCorrectionLevel.Q :
+        return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 2];
+      case QRErrorCorrectionLevel.H :
+        return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 3];
+      default :
+        return undefined;
+      }
+    };
+
+    _this.getRSBlocks = function(typeNumber, errorCorrectionLevel) {
+
+      var rsBlock = getRsBlockTable(typeNumber, errorCorrectionLevel);
+
+      if (typeof rsBlock == 'undefined') {
+        throw 'bad rs block @ typeNumber:' + typeNumber +
+            '/errorCorrectionLevel:' + errorCorrectionLevel;
+      }
+
+      var length = rsBlock.length / 3;
+
+      var list = [];
+
+      for (var i = 0; i < length; i += 1) {
+
+        var count = rsBlock[i * 3 + 0];
+        var totalCount = rsBlock[i * 3 + 1];
+        var dataCount = rsBlock[i * 3 + 2];
+
+        for (var j = 0; j < count; j += 1) {
+          list.push(qrRSBlock(totalCount, dataCount) );
+        }
+      }
+
+      return list;
+    };
+
+    return _this;
+  }();
+
+  //---------------------------------------------------------------------
+  // qrBitBuffer
+  //---------------------------------------------------------------------
+
+  var qrBitBuffer = function() {
+
+    var _buffer = [];
+    var _length = 0;
+
+    var _this = {};
+
+    _this.getBuffer = function() {
+      return _buffer;
+    };
+
+    _this.getAt = function(index) {
+      var bufIndex = Math.floor(index / 8);
+      return ( (_buffer[bufIndex] >>> (7 - index % 8) ) & 1) == 1;
+    };
+
+    _this.put = function(num, length) {
+      for (var i = 0; i < length; i += 1) {
+        _this.putBit( ( (num >>> (length - i - 1) ) & 1) == 1);
+      }
+    };
+
+    _this.getLengthInBits = function() {
+      return _length;
+    };
+
+    _this.putBit = function(bit) {
+
+      var bufIndex = Math.floor(_length / 8);
+      if (_buffer.length <= bufIndex) {
+        _buffer.push(0);
+      }
+
+      if (bit) {
+        _buffer[bufIndex] |= (0x80 >>> (_length % 8) );
+      }
+
+      _length += 1;
+    };
+
+    return _this;
+  };
+
+  //---------------------------------------------------------------------
+  // qrNumber
+  //---------------------------------------------------------------------
+
+  var qrNumber = function(data) {
+
+    var _mode = QRMode.MODE_NUMBER;
+    var _data = data;
+
+    var _this = {};
+
+    _this.getMode = function() {
+      return _mode;
+    };
+
+    _this.getLength = function(buffer) {
+      return _data.length;
+    };
+
+    _this.write = function(buffer) {
+
+      var data = _data;
+
+      var i = 0;
+
+      while (i + 2 < data.length) {
+        buffer.put(strToNum(data.substring(i, i + 3) ), 10);
+        i += 3;
+      }
+
+      if (i < data.length) {
+        if (data.length - i == 1) {
+          buffer.put(strToNum(data.substring(i, i + 1) ), 4);
+        } else if (data.length - i == 2) {
+          buffer.put(strToNum(data.substring(i, i + 2) ), 7);
+        }
+      }
+    };
+
+    var strToNum = function(s) {
+      var num = 0;
+      for (var i = 0; i < s.length; i += 1) {
+        num = num * 10 + chatToNum(s.charAt(i) );
+      }
+      return num;
+    };
+
+    var chatToNum = function(c) {
+      if ('0' <= c && c <= '9') {
+        return c.charCodeAt(0) - '0'.charCodeAt(0);
+      }
+      throw 'illegal char :' + c;
+    };
+
+    return _this;
+  };
+
+  //---------------------------------------------------------------------
+  // qrAlphaNum
+  //---------------------------------------------------------------------
+
+  var qrAlphaNum = function(data) {
+
+    var _mode = QRMode.MODE_ALPHA_NUM;
+    var _data = data;
+
+    var _this = {};
+
+    _this.getMode = function() {
+      return _mode;
+    };
+
+    _this.getLength = function(buffer) {
+      return _data.length;
+    };
+
+    _this.write = function(buffer) {
+
+      var s = _data;
+
+      var i = 0;
+
+      while (i + 1 < s.length) {
+        buffer.put(
+          getCode(s.charAt(i) ) * 45 +
+          getCode(s.charAt(i + 1) ), 11);
+        i += 2;
+      }
+
+      if (i < s.length) {
+        buffer.put(getCode(s.charAt(i) ), 6);
+      }
+    };
+
+    var getCode = function(c) {
+
+      if ('0' <= c && c <= '9') {
+        return c.charCodeAt(0) - '0'.charCodeAt(0);
+      } else if ('A' <= c && c <= 'Z') {
+        return c.charCodeAt(0) - 'A'.charCodeAt(0) + 10;
+      } else {
+        switch (c) {
+        case ' ' : return 36;
+        case '$' : return 37;
+        case '%' : return 38;
+        case '*' : return 39;
+        case '+' : return 40;
+        case '-' : return 41;
+        case '.' : return 42;
+        case '/' : return 43;
+        case ':' : return 44;
+        default :
+          throw 'illegal char :' + c;
+        }
+      }
+    };
+
+    return _this;
+  };
+
+  //---------------------------------------------------------------------
+  // qr8BitByte
+  //---------------------------------------------------------------------
+
+  var qr8BitByte = function(data) {
+
+    var _mode = QRMode.MODE_8BIT_BYTE;
+    var _data = data;
+    var _bytes = qrcode.stringToBytes(data);
+
+    var _this = {};
+
+    _this.getMode = function() {
+      return _mode;
+    };
+
+    _this.getLength = function(buffer) {
+      return _bytes.length;
+    };
+
+    _this.write = function(buffer) {
+      for (var i = 0; i < _bytes.length; i += 1) {
+        buffer.put(_bytes[i], 8);
+      }
+    };
+
+    return _this;
+  };
+
+  //---------------------------------------------------------------------
+  // qrKanji
+  //---------------------------------------------------------------------
+
+  var qrKanji = function(data) {
+
+    var _mode = QRMode.MODE_KANJI;
+    var _data = data;
+
+    var stringToBytes = qrcode.stringToBytesFuncs['SJIS'];
+    if (!stringToBytes) {
+      throw 'sjis not supported.';
+    }
+    !function(c, code) {
+      // self test for sjis support.
+      var test = stringToBytes(c);
+      if (test.length != 2 || ( (test[0] << 8) | test[1]) != code) {
+        throw 'sjis not supported.';
+      }
+    }('\u53cb', 0x9746);
+
+    var _bytes = stringToBytes(data);
+
+    var _this = {};
+
+    _this.getMode = function() {
+      return _mode;
+    };
+
+    _this.getLength = function(buffer) {
+      return ~~(_bytes.length / 2);
+    };
+
+    _this.write = function(buffer) {
+
+      var data = _bytes;
+
+      var i = 0;
+
+      while (i + 1 < data.length) {
+
+        var c = ( (0xff & data[i]) << 8) | (0xff & data[i + 1]);
+
+        if (0x8140 <= c && c <= 0x9FFC) {
+          c -= 0x8140;
+        } else if (0xE040 <= c && c <= 0xEBBF) {
+          c -= 0xC140;
+        } else {
+          throw 'illegal char at ' + (i + 1) + '/' + c;
+        }
+
+        c = ( (c >>> 8) & 0xff) * 0xC0 + (c & 0xff);
+
+        buffer.put(c, 13);
+
+        i += 2;
+      }
+
+      if (i < data.length) {
+        throw 'illegal char at ' + (i + 1);
+      }
+    };
+
+    return _this;
+  };
+
+  //=====================================================================
+  // GIF Support etc.
+  //
+
+  //---------------------------------------------------------------------
+  // byteArrayOutputStream
+  //---------------------------------------------------------------------
+
+  var byteArrayOutputStream = function() {
+
+    var _bytes = [];
+
+    var _this = {};
+
+    _this.writeByte = function(b) {
+      _bytes.push(b & 0xff);
+    };
+
+    _this.writeShort = function(i) {
+      _this.writeByte(i);
+      _this.writeByte(i >>> 8);
+    };
+
+    _this.writeBytes = function(b, off, len) {
+      off = off || 0;
+      len = len || b.length;
+      for (var i = 0; i < len; i += 1) {
+        _this.writeByte(b[i + off]);
+      }
+    };
+
+    _this.writeString = function(s) {
+      for (var i = 0; i < s.length; i += 1) {
+        _this.writeByte(s.charCodeAt(i) );
+      }
+    };
+
+    _this.toByteArray = function() {
+      return _bytes;
+    };
+
+    _this.toString = function() {
+      var s = '';
+      s += '[';
+      for (var i = 0; i < _bytes.length; i += 1) {
+        if (i > 0) {
+          s += ',';
+        }
+        s += _bytes[i];
+      }
+      s += ']';
+      return s;
+    };
+
+    return _this;
+  };
+
+  //---------------------------------------------------------------------
+  // base64EncodeOutputStream
+  //---------------------------------------------------------------------
+
+  var base64EncodeOutputStream = function() {
+
+    var _buffer = 0;
+    var _buflen = 0;
+    var _length = 0;
+    var _base64 = '';
+
+    var _this = {};
+
+    var writeEncoded = function(b) {
+      _base64 += String.fromCharCode(encode(b & 0x3f) );
+    };
+
+    var encode = function(n) {
+      if (n < 0) {
+        // error.
+      } else if (n < 26) {
+        return 0x41 + n;
+      } else if (n < 52) {
+        return 0x61 + (n - 26);
+      } else if (n < 62) {
+        return 0x30 + (n - 52);
+      } else if (n == 62) {
+        return 0x2b;
+      } else if (n == 63) {
+        return 0x2f;
+      }
+      throw 'n:' + n;
+    };
+
+    _this.writeByte = function(n) {
+
+      _buffer = (_buffer << 8) | (n & 0xff);
+      _buflen += 8;
+      _length += 1;
+
+      while (_buflen >= 6) {
+        writeEncoded(_buffer >>> (_buflen - 6) );
+        _buflen -= 6;
+      }
+    };
+
+    _this.flush = function() {
+
+      if (_buflen > 0) {
+        writeEncoded(_buffer << (6 - _buflen) );
+        _buffer = 0;
+        _buflen = 0;
+      }
+
+      if (_length % 3 != 0) {
+        // padding
+        var padlen = 3 - _length % 3;
+        for (var i = 0; i < padlen; i += 1) {
+          _base64 += '=';
+        }
+      }
+    };
+
+    _this.toString = function() {
+      return _base64;
+    };
+
+    return _this;
+  };
+
+  //---------------------------------------------------------------------
+  // base64DecodeInputStream
+  //---------------------------------------------------------------------
+
+  var base64DecodeInputStream = function(str) {
+
+    var _str = str;
+    var _pos = 0;
+    var _buffer = 0;
+    var _buflen = 0;
+
+    var _this = {};
+
+    _this.read = function() {
+
+      while (_buflen < 8) {
+
+        if (_pos >= _str.length) {
+          if (_buflen == 0) {
+            return -1;
+          }
+          throw 'unexpected end of file./' + _buflen;
+        }
+
+        var c = _str.charAt(_pos);
+        _pos += 1;
+
+        if (c == '=') {
+          _buflen = 0;
+          return -1;
+        } else if (c.match(/^\s$/) ) {
+          // ignore if whitespace.
+          continue;
+        }
+
+        _buffer = (_buffer << 6) | decode(c.charCodeAt(0) );
+        _buflen += 6;
+      }
+
+      var n = (_buffer >>> (_buflen - 8) ) & 0xff;
+      _buflen -= 8;
+      return n;
+    };
+
+    var decode = function(c) {
+      if (0x41 <= c && c <= 0x5a) {
+        return c - 0x41;
+      } else if (0x61 <= c && c <= 0x7a) {
+        return c - 0x61 + 26;
+      } else if (0x30 <= c && c <= 0x39) {
+        return c - 0x30 + 52;
+      } else if (c == 0x2b) {
+        return 62;
+      } else if (c == 0x2f) {
+        return 63;
+      } else {
+        throw 'c:' + c;
+      }
+    };
+
+    return _this;
+  };
+
+  //---------------------------------------------------------------------
+  // gifImage (B/W)
+  //---------------------------------------------------------------------
+
+  var gifImage = function(width, height) {
+
+    var _width = width;
+    var _height = height;
+    var _data = new Array(width * height);
+
+    var _this = {};
+
+    _this.setPixel = function(x, y, pixel) {
+      _data[y * _width + x] = pixel;
+    };
+
+    _this.write = function(out) {
+
+      //---------------------------------
+      // GIF Signature
+
+      out.writeString('GIF87a');
+
+      //---------------------------------
+      // Screen Descriptor
+
+      out.writeShort(_width);
+      out.writeShort(_height);
+
+      out.writeByte(0x80); // 2bit
+      out.writeByte(0);
+      out.writeByte(0);
+
+      //---------------------------------
+      // Global Color Map
+
+      // black
+      out.writeByte(0x00);
+      out.writeByte(0x00);
+      out.writeByte(0x00);
+
+      // white
+      out.writeByte(0xff);
+      out.writeByte(0xff);
+      out.writeByte(0xff);
+
+      //---------------------------------
+      // Image Descriptor
+
+      out.writeString(',');
+      out.writeShort(0);
+      out.writeShort(0);
+      out.writeShort(_width);
+      out.writeShort(_height);
+      out.writeByte(0);
+
+      //---------------------------------
+      // Local Color Map
+
+      //---------------------------------
+      // Raster Data
+
+      var lzwMinCodeSize = 2;
+      var raster = getLZWRaster(lzwMinCodeSize);
+
+      out.writeByte(lzwMinCodeSize);
+
+      var offset = 0;
+
+      while (raster.length - offset > 255) {
+        out.writeByte(255);
+        out.writeBytes(raster, offset, 255);
+        offset += 255;
+      }
+
+      out.writeByte(raster.length - offset);
+      out.writeBytes(raster, offset, raster.length - offset);
+      out.writeByte(0x00);
+
+      //---------------------------------
+      // GIF Terminator
+      out.writeString(';');
+    };
+
+    var bitOutputStream = function(out) {
+
+      var _out = out;
+      var _bitLength = 0;
+      var _bitBuffer = 0;
+
+      var _this = {};
+
+      _this.write = function(data, length) {
+
+        if ( (data >>> length) != 0) {
+          throw 'length over';
+        }
+
+        while (_bitLength + length >= 8) {
+          _out.writeByte(0xff & ( (data << _bitLength) | _bitBuffer) );
+          length -= (8 - _bitLength);
+          data >>>= (8 - _bitLength);
+          _bitBuffer = 0;
+          _bitLength = 0;
+        }
+
+        _bitBuffer = (data << _bitLength) | _bitBuffer;
+        _bitLength = _bitLength + length;
+      };
+
+      _this.flush = function() {
+        if (_bitLength > 0) {
+          _out.writeByte(_bitBuffer);
+        }
+      };
+
+      return _this;
+    };
+
+    var getLZWRaster = function(lzwMinCodeSize) {
+
+      var clearCode = 1 << lzwMinCodeSize;
+      var endCode = (1 << lzwMinCodeSize) + 1;
+      var bitLength = lzwMinCodeSize + 1;
+
+      // Setup LZWTable
+      var table = lzwTable();
+
+      for (var i = 0; i < clearCode; i += 1) {
+        table.add(String.fromCharCode(i) );
+      }
+      table.add(String.fromCharCode(clearCode) );
+      table.add(String.fromCharCode(endCode) );
+
+      var byteOut = byteArrayOutputStream();
+      var bitOut = bitOutputStream(byteOut);
+
+      // clear code
+      bitOut.write(clearCode, bitLength);
+
+      var dataIndex = 0;
+
+      var s = String.fromCharCode(_data[dataIndex]);
+      dataIndex += 1;
+
+      while (dataIndex < _data.length) {
+
+        var c = String.fromCharCode(_data[dataIndex]);
+        dataIndex += 1;
+
+        if (table.contains(s + c) ) {
+
+          s = s + c;
+
+        } else {
+
+          bitOut.write(table.indexOf(s), bitLength);
+
+          if (table.size() < 0xfff) {
+
+            if (table.size() == (1 << bitLength) ) {
+              bitLength += 1;
+            }
+
+            table.add(s + c);
+          }
+
+          s = c;
+        }
+      }
+
+      bitOut.write(table.indexOf(s), bitLength);
+
+      // end code
+      bitOut.write(endCode, bitLength);
+
+      bitOut.flush();
+
+      return byteOut.toByteArray();
+    };
+
+    var lzwTable = function() {
+
+      var _map = {};
+      var _size = 0;
+
+      var _this = {};
+
+      _this.add = function(key) {
+        if (_this.contains(key) ) {
+          throw 'dup key:' + key;
+        }
+        _map[key] = _size;
+        _size += 1;
+      };
+
+      _this.size = function() {
+        return _size;
+      };
+
+      _this.indexOf = function(key) {
+        return _map[key];
+      };
+
+      _this.contains = function(key) {
+        return typeof _map[key] != 'undefined';
+      };
+
+      return _this;
+    };
+
+    return _this;
+  };
+
+  var createDataURL = function(width, height, getPixel) {
+    var gif = gifImage(width, height);
+    for (var y = 0; y < height; y += 1) {
+      for (var x = 0; x < width; x += 1) {
+        gif.setPixel(x, y, getPixel(x, y) );
+      }
+    }
+
+    var b = byteArrayOutputStream();
+    gif.write(b);
+
+    var base64 = base64EncodeOutputStream();
+    var bytes = b.toByteArray();
+    for (var i = 0; i < bytes.length; i += 1) {
+      base64.writeByte(bytes[i]);
+    }
+    base64.flush();
+
+    return 'data:image/gif;base64,' + base64;
+  };
+
+  //---------------------------------------------------------------------
+  // returns qrcode function.
+
+  return qrcode;
+}();
+
+// multibyte support
+!function() {
+
+  qrcode.stringToBytesFuncs['UTF-8'] = function(s) {
+    // http://stackoverflow.com/questions/18729405/how-to-convert-utf8-string-to-byte-array
+    function toUTF8Array(str) {
+      var utf8 = [];
+      for (var i=0; i < str.length; i++) {
+        var charcode = str.charCodeAt(i);
+        if (charcode < 0x80) utf8.push(charcode);
+        else if (charcode < 0x800) {
+          utf8.push(0xc0 | (charcode >> 6),
+              0x80 | (charcode & 0x3f));
+        }
+        else if (charcode < 0xd800 || charcode >= 0xe000) {
+          utf8.push(0xe0 | (charcode >> 12),
+              0x80 | ((charcode>>6) & 0x3f),
+              0x80 | (charcode & 0x3f));
+        }
+        // surrogate pair
+        else {
+          i++;
+          // UTF-16 encodes 0x10000-0x10FFFF by
+          // subtracting 0x10000 and splitting the
+          // 20 bits of 0x0-0xFFFFF into two halves
+          charcode = 0x10000 + (((charcode & 0x3ff)<<10)
+            | (str.charCodeAt(i) & 0x3ff));
+          utf8.push(0xf0 | (charcode >>18),
+              0x80 | ((charcode>>12) & 0x3f),
+              0x80 | ((charcode>>6) & 0x3f),
+              0x80 | (charcode & 0x3f));
+        }
+      }
+      return utf8;
+    }
+    return toUTF8Array(s);
+  };
+
+}();
+
+(function (factory) {
+  if (typeof define === 'function' && define.amd) {
+      define([], factory);
+  } else if (typeof exports === 'object') {
+      module.exports = factory();
+  }
+}(function () {
+    return qrcode;
+}));