Skip to content

Conversation

@HauklandJ
Copy link
Contributor

@HauklandJ HauklandJ commented Jun 3, 2025

Description

The explicit format string "dd.MM.yyyy HH:mm:ss" bypasses the complex culture-dependent format resolution that the "G" specifier relies on. While the "G" format specifier is supposed to be culture-aware, it can fall back to system defaults when thread culture is not properly set, even when an explicit culture is provided to the method and DOTNET_SYSTEM_GLOBALIZATION_INVARIANT: false

Another solution would be to update the docs on how to set up the dockerfile for pdf footer.

By adding default culture:

// Set .NET default culture
ENV DOTNET_DefaultCulture=nb-NO
ENV DOTNET_DefaultUICulture=nb-NO

This has not been tested though.

Related Issue(s)

  • #{issue number}

Verification

  • Your code builds clean without any errors or warnings
  • Manual testing done (required)
  • Relevant automated test added (if you find this hard, leave it and we'll help out)
  • All tests run green

Documentation

  • User documentation is updated with a separate linked PR in altinn-studio-docs. (if applicable)

@HauklandJ HauklandJ added kind/bug Something isn't working backport-ignore This PR is a new feature and should not be cherry-picked onto release branches labels Jun 3, 2025
@HauklandJ
Copy link
Contributor Author

/publish

@github-actions
Copy link

github-actions bot commented Jun 3, 2025

PR release:

⚙️ Building...
✅ Done!

@HauklandJ HauklandJ added bugfix Label Pull requests with bugfix. Used when generation releasenotes and removed kind/bug Something isn't working labels Jun 3, 2025
@ivarne
Copy link
Member

ivarne commented Jun 4, 2025

https://docs.altinn.studio//nb/altinn-studio/reference/ux/pdf/#forutsetninger
image

Is it really better to fallback to UTC (in tt02/prod) than to error when someone doesn't read the documentation?

@HauklandJ
Copy link
Contributor Author

/publish

@github-actions
Copy link

github-actions bot commented Jun 4, 2025

PR release:

⚙️ Building...
✅ Done!

@HauklandJ
Copy link
Contributor Author

HauklandJ commented Jun 4, 2025

Testing done:

first test:

Added loggin to the GetFooterContent method:

        _logger.LogInformation("--------------------------------------------------------");
        _logger.LogInformation($"Current culture: {CultureInfo.CurrentCulture.Name}");
        _logger.LogInformation($"Current UI culture: {CultureInfo.CurrentUICulture.Name}");
        _logger.LogInformation(
            $"DOTNET_SYSTEM_GLOBALIZATION_INVARIANT: {Environment.GetEnvironmentVariable("DOTNET_SYSTEM_GLOBALIZATION_INVARIANT")}"
        );

        _logger.LogInformation($"Successfully created nb-NO culture");

        // string dateGenerated = now.ToString("G", nbCulture);
        string dateGenerated = now.ToString("dd.MM.yyyy HH:mm:ss", new CultureInfo("nb-NO"));
        _logger.LogInformation($"Generated date with nb-NO culture: {dateGenerated}");

Output:

Current culture:
Current UI culture:
DOTNET_SYSTEM_GLOBALIZATION_INVARIANT: false
Successfully created nb-NO culture

However, the footer reads:

6/4/2025 11:22:39 ID:2ff52303ad47

successfully create the nb-NO culture, but it seems like something is overriding it or the format string "G" is not behaving as expected.

Tests:

  using System.Globalization;
  using Xunit.Abstractions;
  
  namespace Altinn.App.Core.Tests.Helpers;
  
  public class DateFormattingBehaviorTests
  {
      private readonly ITestOutputHelper _output;
      private readonly DateTime _testDate = new DateTime(2025, 6, 4, 11, 22, 39);
  
      public DateFormattingBehaviorTests(ITestOutputHelper output)
      {
          _output = output;
      }
  
      [Fact]
      public void TestGFormatWithNorwegianCulture_WithEmptyThreadCulture()
      {
          // Arrange - Simulate container environment with empty thread culture
          var originalCulture = Thread.CurrentThread.CurrentCulture;
          var originalUICulture = Thread.CurrentThread.CurrentUICulture;
  
          try
          {
              // Set thread culture to invariant (simulating container behavior)
              Thread.CurrentThread.CurrentCulture = CultureInfo.InvariantCulture;
              Thread.CurrentThread.CurrentUICulture = CultureInfo.InvariantCulture;
  
              // Act
              var gFormatResult = _testDate.ToString("G", new CultureInfo("nb-NO"));
              var explicitFormatResult = _testDate.ToString("dd.MM.yyyy HH:mm:ss", new CultureInfo("nb-NO"));
  
              // Log results
              _output.WriteLine($"Thread Culture: {Thread.CurrentThread.CurrentCulture.Name}");
              _output.WriteLine($"Thread UI Culture: {Thread.CurrentThread.CurrentUICulture.Name}");
              _output.WriteLine($"G Format Result: {gFormatResult}");
              _output.WriteLine($"Explicit Format Result: {explicitFormatResult}");
  
              // Assert - Check if G format behaves as expected
              Assert.Contains(".", gFormatResult); // Norwegian uses dots as date separators
              Assert.DoesNotContain("/", gFormatResult); // Should not contain US-style slashes
  
              // The explicit format should always work correctly
              Assert.Equal("04.06.2025 11:22:39", explicitFormatResult);
          }
          finally
          {
              // Restore original culture
              Thread.CurrentThread.CurrentCulture = originalCulture;
              Thread.CurrentThread.CurrentUICulture = originalUICulture;
          }
      }
  
      [Fact]
      public void TestGFormatWithNorwegianCulture_WithUSThreadCulture()
      {
          // Arrange - Simulate environment with US thread culture
          var originalCulture = Thread.CurrentThread.CurrentCulture;
          var originalUICulture = Thread.CurrentThread.CurrentUICulture;
  
          try
          {
              // Set thread culture to US (potential interference)
              Thread.CurrentThread.CurrentCulture = new CultureInfo("en-US");
              Thread.CurrentThread.CurrentUICulture = new CultureInfo("en-US");
  
              // Act
              var gFormatResult = _testDate.ToString("G", new CultureInfo("nb-NO"));
              var explicitFormatResult = _testDate.ToString("dd.MM.yyyy HH:mm:ss", new CultureInfo("nb-NO"));
  
              // Log results
              _output.WriteLine($"Thread Culture: {Thread.CurrentThread.CurrentCulture.Name}");
              _output.WriteLine($"Thread UI Culture: {Thread.CurrentThread.CurrentUICulture.Name}");
              _output.WriteLine($"G Format Result: {gFormatResult}");
              _output.WriteLine($"Explicit Format Result: {explicitFormatResult}");
  
              // Assert
              Assert.Contains(".", gFormatResult); // Should still use Norwegian formatting
              Assert.DoesNotContain("/", gFormatResult);
              Assert.Equal("04.06.2025 11:22:39", explicitFormatResult);
          }
          finally
          {
              Thread.CurrentThread.CurrentCulture = originalCulture;
              Thread.CurrentThread.CurrentUICulture = originalUICulture;
          }
      }
  
      [Theory]
      [InlineData("nb-NO", "04.06.2025 11:22:39")]
      [InlineData("en-US", "6/4/2025 11:22:39 AM")]
      [InlineData("en-GB", "04/06/2025 11:22:39")]
      [InlineData("de-DE", "04.06.2025 11:22:39")]
      [InlineData("fr-FR", "04/06/2025 11:22:39")]
      public void TestExplicitFormatConsistency(string cultureName, string expectedExplicitFormat)
      {
          // Arrange
          var culture = new CultureInfo(cultureName);
          var explicitFormat = cultureName == "en-US" ? "M/d/yyyy h:mm:ss tt" : "dd.MM.yyyy HH:mm:ss";
  
          // Act
          var gFormatResult = _testDate.ToString("G", culture);
          var explicitFormatResult = _testDate.ToString(explicitFormat, culture);
  
          // Log results
          _output.WriteLine($"Culture: {cultureName}");
          _output.WriteLine($"G Format: {gFormatResult}");
          _output.WriteLine($"Explicit Format: {explicitFormatResult}");
          _output.WriteLine($"Expected: {expectedExplicitFormat}");
          _output.WriteLine("---");
  
          // Assert - Explicit format should match expected
          Assert.Equal(expectedExplicitFormat, explicitFormatResult);
      }
  
      [Fact]
      public void TestCultureCreationAndProperties()
      {
          // Test culture creation behavior similar to your logs
          var originalCulture = Thread.CurrentThread.CurrentCulture;
          var originalUICulture = Thread.CurrentThread.CurrentUICulture;
  
          try
          {
              // Simulate container environment
              Thread.CurrentThread.CurrentCulture = CultureInfo.InvariantCulture;
              Thread.CurrentThread.CurrentUICulture = CultureInfo.InvariantCulture;
  
              // Test Norwegian culture creation
              CultureInfo nbCulture;
              var cultureCreationSucceeded = false;
  
              try
              {
                  nbCulture = new CultureInfo("nb-NO");
                  cultureCreationSucceeded = true;
                  _output.WriteLine("Successfully created nb-NO culture");
              }
              catch (CultureNotFoundException ex)
              {
                  _output.WriteLine($"Failed to create nb-NO culture: {ex.Message}");
                  nbCulture = CultureInfo.InvariantCulture;
              }
  
              // Log culture information
              _output.WriteLine($"Current culture: '{Thread.CurrentThread.CurrentCulture.Name}'");
              _output.WriteLine($"Current UI culture: '{Thread.CurrentThread.CurrentUICulture.Name}'");
              _output.WriteLine($"Norwegian culture name: {nbCulture.Name}");
              _output.WriteLine($"Norwegian culture display name: {nbCulture.DisplayName}");
  
              // Test date formatting with created culture
              var gFormat = _testDate.ToString("G", nbCulture);
              var explicitFormat = _testDate.ToString("dd.MM.yyyy HH:mm:ss", nbCulture);
  
              _output.WriteLine($"G format result: {gFormat}");
              _output.WriteLine($"Explicit format result: {explicitFormat}");
  
              // Assertions
              Assert.True(cultureCreationSucceeded, "Should be able to create nb-NO culture");
              Assert.Equal("nb-NO", nbCulture.Name);
              Assert.Equal("04.06.2025 11:22:39", explicitFormat);
          }
          finally
          {
              Thread.CurrentThread.CurrentCulture = originalCulture;
              Thread.CurrentThread.CurrentUICulture = originalUICulture;
          }
      }
  
      [Fact]
      public void TestFormatSpecifierBehaviorComparison()
      {
          // Test different format specifiers with Norwegian culture
          var nbCulture = new CultureInfo("nb-NO");
  
          var formats = new[]
          {
              ("G", "General date/time pattern (short time)"),
              ("g", "General date/time pattern (long time)"),
              ("F", "Full date/time pattern (long time)"),
              ("f", "Full date/time pattern (short time)"),
              ("dd.MM.yyyy HH:mm:ss", "Explicit Norwegian format"),
          };
  
          _output.WriteLine("Format Specifier Comparison:");
          _output.WriteLine("============================");
  
          foreach (var (format, description) in formats)
          {
              try
              {
                  var result = _testDate.ToString(format, nbCulture);
                  _output.WriteLine($"{format, -20} | {description, -35} | {result}");
  
                  // Test that explicit format produces expected result
                  if (format == "dd.MM.yyyy HH:mm:ss")
                  {
                      Assert.Equal("04.06.2025 11:22:39", result);
                  }
              }
              catch (Exception ex)
              {
                  _output.WriteLine($"{format, -20} | {description, -35} | ERROR: {ex.Message}");
              }
          }
      }
  
      [Fact]
      public void TestEnvironmentVariableSimulation()
      {
          // Test the effect of DOTNET_SYSTEM_GLOBALIZATION_INVARIANT
          var globalizationInvariant = Environment.GetEnvironmentVariable("DOTNET_SYSTEM_GLOBALIZATION_INVARIANT");
  
          _output.WriteLine($"DOTNET_SYSTEM_GLOBALIZATION_INVARIANT: {globalizationInvariant ?? "not set"}");
  
          // Test culture availability
          var availableCultures = CultureInfo.GetCultures(CultureTypes.AllCultures);
          var norwegianCultures = Array.FindAll(
              availableCultures,
              c => c.Name.StartsWith("nb") || c.Name.StartsWith("no")
          );
  
          _output.WriteLine($"Total available cultures: {availableCultures.Length}");
          _output.WriteLine("Norwegian-related cultures found:");
  
          foreach (var culture in norwegianCultures)
          {
              _output.WriteLine($"  - {culture.Name}: {culture.DisplayName}");
          }
  
          Assert.True(norwegianCultures.Length > 0, "Should find at least one Norwegian culture");
      }
  
      [Fact]
      public void TestActualPdfServiceScenario()
      {
          // Simulate the exact scenario from your PdfService
          var originalCulture = Thread.CurrentThread.CurrentCulture;
          var originalUICulture = Thread.CurrentThread.CurrentUICulture;
  
          try
          {
              // Simulate empty thread culture (as seen in your logs)
              Thread.CurrentThread.CurrentCulture = CultureInfo.InvariantCulture;
              Thread.CurrentThread.CurrentUICulture = CultureInfo.InvariantCulture;
  
              // Simulate timezone conversion (Europe/Oslo)
              TimeZoneInfo timeZone;
              try
              {
                  timeZone = TimeZoneInfo.FindSystemTimeZoneById("Europe/Oslo");
              }
              catch (TimeZoneNotFoundException)
              {
                  timeZone = TimeZoneInfo.Utc;
                  _output.WriteLine("Europe/Oslo timezone not found, using UTC");
              }
  
              var utcTime = new DateTime(2025, 6, 4, 9, 22, 39, DateTimeKind.Utc); // UTC time
              var norwayTime = TimeZoneInfo.ConvertTime(utcTime, timeZone);
  
              // Test both approaches
              var gFormatResult = norwayTime.ToString("G", new CultureInfo("nb-NO"));
              var explicitFormatResult = norwayTime.ToString("dd.MM.yyyy HH:mm:ss", new CultureInfo("nb-NO"));
  
              _output.WriteLine($"UTC Time: {utcTime}");
              _output.WriteLine($"Norway Time: {norwayTime}");
              _output.WriteLine($"G Format: {gFormatResult}");
              _output.WriteLine($"Explicit Format: {explicitFormatResult}");
  
              // The explicit format should always work
              Assert.Matches(@"\d{2}\.\d{2}\.\d{4} \d{2}:\d{2}:\d{2}", explicitFormatResult);
  
              // Check if G format contains Norwegian-style separators
              var containsNorwegianSeparators = gFormatResult.Contains(".") && !gFormatResult.Contains("/");
              _output.WriteLine($"G format uses Norwegian separators: {containsNorwegianSeparators}");
          }
          finally
          {
              Thread.CurrentThread.CurrentCulture = originalCulture;
              Thread.CurrentThread.CurrentUICulture = originalUICulture;
          }
      }
  }
  
  // Helper class to test culture behavior in different scenarios
  public static class CultureTestHelpers
  {
      public static void SimulateContainerEnvironment(Action testAction)
      {
          var originalCulture = Thread.CurrentThread.CurrentCulture;
          var originalUICulture = Thread.CurrentThread.CurrentUICulture;
  
          try
          {
              Thread.CurrentThread.CurrentCulture = CultureInfo.InvariantCulture;
              Thread.CurrentThread.CurrentUICulture = CultureInfo.InvariantCulture;
              testAction();
          }
          finally
          {
              Thread.CurrentThread.CurrentCulture = originalCulture;
              Thread.CurrentThread.CurrentUICulture = originalUICulture;
          }
      }
  
      public static string GetDetailedCultureInfo(CultureInfo culture)
      {
          return $"Name: {culture.Name}, "
              + $"DisplayName: {culture.DisplayName}, "
              + $"TwoLetterISOLanguageName: {culture.TwoLetterISOLanguageName}, "
              + $"IsNeutralCulture: {culture.IsNeutralCulture}";
      }
  }

Results:

TestGFormatWithNorwegianCulture_WithEmptyThreadCulture passed
TestGFormatWithNorwegianCulture_WithUSThreadCulture passed
TestCultureCreationAndProperties passed
TestFormatSpecifierBehaviorComparison passed
TestEnvironmentVariableSimulation passed
TestActualPdfServiceScenario passed
TestExplicitFormatConsistency failed
de-DE passed
nb-NO passed
en-US passed
en-GB failed
fr-FR failed

Conclusion:

Local Environment:

.NET culture formatting works correctly
Norwegian culture creation succeeds
"G" format specifier produces correct Norwegian dates
Some cultures still have issues (en-GB, fr-FR)

Next test:
Using explicit date format in tt02

@HauklandJ
Copy link
Contributor Author

/publish

@github-actions
Copy link

github-actions bot commented Jun 4, 2025

PR release:

⚙️ Building...
✅ Done!

@HauklandJ
Copy link
Contributor Author

Explicit date format:

The explicit format string "dd.MM.yyyy HH:mm:ss" bypasses the complex culture-dependent format resolution that the "G" specifier relies on. While the "G" format specifier is supposed to be culture-aware, it can fall back to system defaults when thread culture is not properly set, even when an explicit culture is provided to the method.

The container logs confirmed the fix worked:
Generated date with nb-NO culture: 04.06.2025 12:35:41
Footer content: 04.06.2025 12:35:41
And the final PDF footer displayed the correct Norwegian format: 04.06.2025 12:35:41 ID:fac3fbcde98e

@HauklandJ HauklandJ marked this pull request as ready for review June 4, 2025 10:43
@HauklandJ HauklandJ linked an issue Jun 4, 2025 that may be closed by this pull request
@HauklandJ HauklandJ changed the title fallback to invariant if culture not found Footer in generated pdf should use dd.MM.yyyy HH:mm:ss Jun 4, 2025
Copy link
Member

@bjosttveit bjosttveit left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me, but are seconds really necessary?

@sonarqubecloud
Copy link

sonarqubecloud bot commented Jun 4, 2025

@sonarqubecloud
Copy link

sonarqubecloud bot commented Jun 4, 2025

Please retry analysis of this Pull-Request directly on SonarQube Cloud

@HauklandJ HauklandJ merged commit 50e5775 into main Jun 4, 2025
12 checks passed
@HauklandJ HauklandJ deleted the support-invariant-culture branch June 4, 2025 12:22
martinothamar pushed a commit that referenced this pull request Jul 2, 2025
* fallback to invariant if culture not found

* add logging

* throw if misconfigured

* using explicit date format

* remove logging

* including seconds is not necessary

* rm bool literal
martinothamar pushed a commit that referenced this pull request Jul 2, 2025
* fallback to invariant if culture not found

* add logging

* throw if misconfigured

* using explicit date format

* remove logging

* including seconds is not necessary

* rm bool literal
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport-ignore This PR is a new feature and should not be cherry-picked onto release branches bugfix Label Pull requests with bugfix. Used when generation releasenotes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Wrong date-format for timestamp in the generated pdf

3 participants