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:53 UTC

(skywalking-website) branch feat/certificate created (now daded30c9489)

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

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


      at daded30c9489 Add the Certificate of Contribution

This branch includes the following new commits:

     new daded30c9489 Add the Certificate of Contribution

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



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

Posted by wu...@apache.org.
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;
+}));